Coverage

78.3
4317
300344
937

deps/mox/lib/mox.ex

100
1
22
0
Line Hits Source
0 defmodule Mox do
1 @moduledoc ~S"""
2 Mox is a library for defining concurrent mocks in Elixir.
3
4 The library follows the principles outlined in
5 ["Mocks and explicit contracts"](https://dashbit.co/blog/mocks-and-explicit-contracts),
6 summarized below:
7
8 1. No ad-hoc mocks. You can only create mocks based on behaviours
9
10 2. No dynamic generation of modules during tests. Mocks are preferably defined
11 in your `test_helper.exs` or in a `setup_all` block and not per test
12
13 3. Concurrency support. Tests using the same mock can still use `async: true`
14
15 4. Rely on pattern matching and function clauses for asserting on the
16 input instead of complex expectation rules
17
18 ## Example
19
20 Imagine that you have an app that has to display the weather. At first,
21 you use an external API to give you the data given a lat/long pair:
22
23 defmodule MyApp.HumanizedWeather do
24 def display_temp({lat, long}) do
25 {:ok, temp} = MyApp.Weather.temp({lat, long})
26 "Current temperature is #{temp} degrees"
27 end
28
29 def display_humidity({lat, long}) do
30 {:ok, humidity} = MyApp.Weather.humidity({lat, long})
31 "Current humidity is #{humidity}%"
32 end
33 end
34
35 However, you want to test the code above without performing external
36 API calls. How to do so?
37
38 First, it is important to define the `Weather` behaviour that we want
39 to mock. And we will define a proxy functions that will dispatch to
40 the desired implementation:
41
42 defmodule MyApp.WeatherAPI do
43 @callback temp(MyApp.LatLong.t()) :: {:ok, integer()}
44 @callback humidity(MyApp.LatLong.t()) :: {:ok, integer()}
45
46 def temp(lat_long), do: impl().temp(lat_long)
47 def humidity(lat_long), do: impl().humidity(lat_long)
48 defp impl, do: Application.get_env(:my_app, :weather, MyApp.ExternalWeatherAPI)
49 end
50
51 By default, we will dispatch to MyApp.ExternalWeatherAPI, which now contains
52 the external API implementation.
53
54 If you want to mock the WeatherAPI behaviour during tests, the first step
55 is to define the mock with `defmock/2`, usually in your `test_helper.exs`,
56 and configure your application to use it:
57
58 Mox.defmock(MyApp.MockWeatherAPI, for: MyApp.Weather)
59 Application.put_env(:my_app, :weather, MyApp.MockWeatherAPI)
60
61 Now in your tests, you can define expectations with `expect/4` and verify
62 them via `verify_on_exit!/1`:
63
64 defmodule MyApp.HumanizedWeatherTest do
65 use ExUnit.Case, async: true
66
67 import Mox
68
69 # Make sure mocks are verified when the test exits
70 setup :verify_on_exit!
71
72 test "gets and formats temperature and humidity" do
73 MyApp.MockWeatherAPI
74 |> expect(:temp, fn {_lat, _long} -> {:ok, 30} end)
75 |> expect(:humidity, fn {_lat, _long} -> {:ok, 60} end)
76
77 assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) ==
78 "Current temperature is 30 degrees"
79
80 assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) ==
81 "Current humidity is 60%"
82 end
83 end
84
85 All expectations are defined based on the current process. This
86 means multiple tests using the same mock can still run concurrently
87 unless the Mox is set to global mode. See the "Multi-process collaboration"
88 section.
89
90 One last note, if the mock is used throughout the test suite, you might want
91 the implementation to fall back to a stub (or actual) implementation when no
92 expectations are defined. You can use `stub_with/2` in your `test_helper.exs`
93 as follows:
94
95 Mox.stub_with(MyApp.MockWeatherAPI, MyApp.StubWeatherAPI)
96
97 Now, if no expectations are defined it will call the implementation in
98 `MyApp.StubWeatherAPI`.
99
100 ## Multiple behaviours
101
102 Mox supports defining mocks for multiple behaviours.
103
104 Suppose your library also defines a behaviour for getting past weather:
105
106 defmodule MyApp.PastWeather do
107 @callback past_temp(MyApp.LatLong.t(), DateTime.t()) :: {:ok, integer()}
108 end
109
110 You can mock both the weather and past weather behaviour:
111
112 Mox.defmock(MyApp.MockWeatherAPI, for: [MyApp.Weather, MyApp.PastWeather])
113
114 ## Compile-time requirements
115
116 If the mock needs to be available during the project compilation, for
117 instance because you get undefined function warnings, then instead of
118 defining the mock in your `test_helper.exs`, you should instead define
119 it under `test/support/mocks.ex`:
120
121 Mox.defmock(MyApp.MockWeatherAPI, for: MyApp.WeatherAPI)
122
123 Then you need to make sure that files in `test/support` get compiled
124 with the rest of the project. Edit your `mix.exs` file to add the
125 `test/support` directory to compilation paths:
126
127 def project do
128 [
129 ...
130 elixirc_paths: elixirc_paths(Mix.env),
131 ...
132 ]
133 end
134
135 defp elixirc_paths(:test), do: ["test/support", "lib"]
136 defp elixirc_paths(_), do: ["lib"]
137
138 ## Multi-process collaboration
139
140 Mox supports multi-process collaboration via two mechanisms:
141
142 1. explicit allowances
143 2. global mode
144
145 The allowance mechanism can still run tests concurrently while
146 the global one doesn't. We explore both next.
147
148 ### Explicit allowances
149
150 An allowance permits a child process to use the expectations and stubs
151 defined in the parent process while still being safe for async tests.
152
153 test "invokes add and mult from a task" do
154 MyApp.MockWeatherAPI
155 |> expect(:temp, fn _loc -> {:ok, 30} end)
156 |> expect(:humidity, fn _loc -> {:ok, 60} end)
157
158 parent_pid = self()
159
160 Task.async(fn ->
161 MyApp.MockWeatherAPI |> allow(parent_pid, self())
162
163 assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) ==
164 "Current temperature is 30 degrees"
165
166 assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) ==
167 "Current humidity is 60%"
168 end)
169 |> Task.await
170 end
171
172 Note: if you're running on Elixir 1.8.0 or greater and your concurrency comes
173 from a `Task` then you don't need to add explicit allowances. Instead
174 `$callers` is used to determine the process that actually defined the
175 expectations.
176
177 ### Global mode
178
179 Mox supports global mode, where any process can consume mocks and stubs
180 defined in your tests. `set_mox_from_context/0` automatically calls
181 `set_mox_global/1` but only if the test context **doesn't** include
182 `async: true`.
183
184 By default the mode is `:private`.
185
186 setup :set_mox_from_context
187 setup :verify_on_exit!
188
189 test "invokes add and mult from a task" do
190 MyApp.MockWeatherAPI
191 |> expect(:temp, fn _loc -> {:ok, 30} end)
192 |> expect(:humidity, fn _loc -> {:ok, 60} end)
193
194 Task.async(fn ->
195 assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) ==
196 "Current temperature is 30 degrees"
197
198 assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) ==
199 "Current humidity is 60%"
200 end)
201 |> Task.await
202 end
203
204 ### Blocking on expectations
205
206 If your mock is called in a different process than the test process,
207 in some cases there is a chance that the test will finish executing
208 before it has a chance to call the mock and meet the expectations.
209 Imagine this:
210
211 test "calling a mock from a different process" do
212 expect(MyApp.MockWeatherAPI, :temp, fn _loc -> {:ok, 30} end)
213
214 spawn(fn -> MyApp.HumanizedWeather.temp({50.06, 19.94}) end)
215
216 verify!()
217 end
218
219 The test above has a race condition because there is a chance that the
220 `verify!/0` call will happen before the spawned process calls the mock.
221 In most cases, you don't control the spawning of the process so you can't
222 simply monitor the process to know when it dies in order to avoid this
223 race condition. In those cases, the way to go is to "sync up" with the
224 process that calls the mock by sending a message to the test process
225 from the expectation and using that to know when the expectation has been
226 called.
227
228 test "calling a mock from a different process" do
229 parent = self()
230 ref = make_ref()
231
232 expect(MyApp.MockWeatherAPI, :temp, fn _loc ->
233 send(parent, {ref, :temp})
234 {:ok, 30}
235 end)
236
237 spawn(fn -> MyApp.HumanizedWeather.temp({50.06, 19.94}) end)
238
239 assert_receive {^ref, :temp}
240
241 verify!()
242 end
243
244 This way, we'll wait until the expectation is called before calling
245 `verify!/0`.
246 """
247
248 defmodule UnexpectedCallError do
249 defexception [:message]
250 end
251
252 defmodule VerificationError do
253 defexception [:message]
254 end
255
256 @doc """
257 Sets the Mox to private mode.
258
259 In private mode, mocks can be set and consumed by the same
260 process unless other processes are explicitly allowed.
261
262 ## Examples
263
264 setup :set_mox_private
265
266 """
267 def set_mox_private(_context \\ %{}), do: Mox.Server.set_mode(self(), :private)
268
269 @doc """
270 Sets the Mox to global mode.
271
272 In global mode, mocks can be consumed by any process.
273
274 An ExUnit case where tests use Mox in global mode cannot be
275 `async: true`.
276
277 ## Examples
278
279 setup :set_mox_global
280 """
281 def set_mox_global(context \\ %{}) do
282 if Map.get(context, :async) do
283 raise "Mox cannot be set to global mode when the ExUnit case is async. " <>
284 "If you want to use Mox in global mode, remove \"async: true\" when using ExUnit.Case"
285 else
286 Mox.Server.set_mode(self(), :global)
287 end
288 end
289
290 @doc """
291 Chooses the Mox mode based on context.
292
293 When `async: true` is used, `set_mox_private/1` is called,
294 otherwise `set_mox_global/1` is used.
295
296 ## Examples
297
298 setup :set_mox_from_context
299
300 """
301 def set_mox_from_context(%{async: true} = _context), do: set_mox_private()
302 def set_mox_from_context(_context), do: set_mox_global()
303
304 @doc """
305 Defines a mock with the given name `:for` the given behaviour(s).
306
307 Mox.defmock(MyMock, for: MyBehaviour)
308
309 With multiple behaviours:
310
311 Mox.defmock(MyMock, for: [MyBehaviour, MyOtherBehaviour])
312
313 ## Skipping optional callbacks
314
315 By default, functions are created for all the behaviour's callbacks,
316 including optional ones. But if for some reason you want to skip one or more
317 of its `@optional_callbacks`, you can provide the list of callback names to
318 skip (along with their arities) as `:skip_optional_callbacks`:
319
320 Mox.defmock(MyMock, for: MyBehaviour, skip_optional_callbacks: [on_success: 2])
321
322 This will define a new mock (`MyMock`) that has a defined function for each
323 callback on `MyBehaviour` except for `on_success/2`. Note: you can only skip
324 optional callbacks, not required callbacks.
325
326 You can also pass `true` to skip all optional callbacks, or `false` to keep
327 the default of generating functions for all optional callbacks.
328
329 ## Passing `@moduledoc`
330
331 You can provide value for `@moduledoc` with `:moduledoc` option.
332
333 Mox.defmock(MyMock, for: MyBehaviour, moduledoc: false)
334 Mox.defmock(MyMock, for: MyBehaviour, moduledoc: "My mock module.")
335
336 """
337 def defmock(name, options) when is_atom(name) and is_list(options) do
338 behaviours =
339 case Keyword.fetch(options, :for) do
340 {:ok, mocks} -> List.wrap(mocks)
341 :error -> raise ArgumentError, ":for option is required on defmock"
342 end
343
344 skip_optional_callbacks = Keyword.get(options, :skip_optional_callbacks, [])
345 moduledoc = Keyword.get(options, :moduledoc, false)
346
347 doc_header = generate_doc_header(moduledoc)
348 compile_header = generate_compile_time_dependency(behaviours)
349 callbacks_to_skip = validate_skip_optional_callbacks!(behaviours, skip_optional_callbacks)
350 mock_funs = generate_mock_funs(behaviours, callbacks_to_skip)
351
352 define_mock_module(name, behaviours, doc_header ++ compile_header ++ mock_funs)
353
354 name
355 end
356
357 defp validate_behaviour!(behaviour) do
358 cond do
359 Code.ensure_compiled(behaviour) != {:module, behaviour} ->
360 raise ArgumentError,
361 "module #{inspect(behaviour)} is not available, please pass an existing module to :for"
362
363 not function_exported?(behaviour, :behaviour_info, 1) ->
364 raise ArgumentError,
365 "module #{inspect(behaviour)} is not a behaviour, please pass a behaviour to :for"
366
367 true ->
368 behaviour
369 end
370 end
371
372 defp generate_doc_header(moduledoc) do
373 [
374 quote do
375 @moduledoc unquote(moduledoc)
376 end
377 ]
378 end
379
380 defp generate_compile_time_dependency(behaviours) do
381 for behaviour <- behaviours do
382 validate_behaviour!(behaviour)
383
384 quote do
385 @behaviour unquote(behaviour)
386 unquote(behaviour).module_info(:module)
387 end
388 end
389 end
390
391 defp generate_mock_funs(behaviours, callbacks_to_skip) do
392 for behaviour <- behaviours,
393 {fun, arity} <- behaviour.behaviour_info(:callbacks),
394 {fun, arity} not in callbacks_to_skip do
395 args = 0..arity |> Enum.to_list() |> tl() |> Enum.map(&Macro.var(:"arg#{&1}", Elixir))
396
397 quote do
398 def unquote(fun)(unquote_splicing(args)) do
399 Mox.__dispatch__(__MODULE__, unquote(fun), unquote(arity), unquote(args))
400 end
401 end
402 end
403 end
404
405 defp validate_skip_optional_callbacks!(behaviours, skip_optional_callbacks) do
406 all_optional_callbacks =
407 for behaviour <- behaviours,
408 {fun, arity} <- behaviour.behaviour_info(:optional_callbacks) do
409 {fun, arity}
410 end
411
412 case skip_optional_callbacks do
413 false ->
414 []
415
416 true ->
417 all_optional_callbacks
418
419 skip_list when is_list(skip_list) ->
420 for callback <- skip_optional_callbacks, callback not in all_optional_callbacks do
421 raise ArgumentError,
422 "all entries in :skip_optional_callbacks must be an optional callback in one " <>
423 "of the behaviours specified in :for. #{inspect(callback)} was not in the " <>
424 "list of all optional callbacks: #{inspect(all_optional_callbacks)}"
425 end
426
427 skip_list
428
429 _ ->
430 raise ArgumentError, ":skip_optional_callbacks is required to be a list or boolean"
431 end
432 end
433
434 defp define_mock_module(name, behaviours, body) do
435 info =
436 quote do
437 def __mock_for__ do
438 unquote(behaviours)
439 end
440 end
441
442 22 Module.create(name, [info | body], Macro.Env.location(__ENV__))
443 end
444
445 @doc """
446 Expects the `name` in `mock` with arity given by `code`
447 to be invoked `n` times.
448
449 If you're calling your mock from an asynchronous process and want
450 to wait for the mock to be called, see the "Blocking on expectations"
451 section in the module documentation.
452
453 When `expect/4` is invoked, any previously declared `stub` for the same `name` and arity will
454 be removed. This ensures that `expect` will fail if the function is called more than `n` times.
455 If a `stub/3` is invoked **after** `expect/4` for the same `name` and arity, the stub will be
456 used after all expectations are fulfilled.
457
458 ## Examples
459
460 To expect `MockWeatherAPI.get_temp/1` to be called once:
461
462 expect(MockWeatherAPI, :get_temp, fn _ -> {:ok, 30} end)
463
464 To expect `MockWeatherAPI.get_temp/1` to be called five times:
465
466 expect(MockWeatherAPI, :get_temp, 5, fn _ -> {:ok, 30} end)
467
468 To expect `MockWeatherAPI.get_temp/1` not to be called:
469
470 expect(MockWeatherAPI, :get_temp, 0, fn _ -> {:ok, 30} end)
471
472 `expect/4` can also be invoked multiple times for the same name/arity,
473 allowing you to give different behaviours on each invocation. For instance,
474 you could test that your code will try an API call three times before giving
475 up:
476
477 MockWeatherAPI
478 |> expect(:get_temp, 2, fn _loc -> {:error, :unreachable} end)
479 |> expect(:get_temp, 1, fn _loc -> {:ok, 30} end)
480
481 log = capture_log(fn ->
482 assert Weather.current_temp(location)
483 == "It's currently 30 degrees"
484 end)
485
486 assert log =~ "attempt 1 failed"
487 assert log =~ "attempt 2 failed"
488 assert log =~ "attempt 3 succeeded"
489
490 MockWeatherAPI
491 |> expect(:get_temp, 3, fn _loc -> {:error, :unreachable} end)
492
493 assert Weather.current_temp(location) == "Current temperature is unavailable"
494 """
495 def expect(mock, name, n \\ 1, code)
496 when is_atom(mock) and is_atom(name) and is_integer(n) and n >= 0 and is_function(code) do
497 calls = List.duplicate(code, n)
498 add_expectation!(mock, name, code, {n, calls, nil})
499 mock
500 end
501
502 @doc """
503 Allows the `name` in `mock` with arity given by `code` to
504 be invoked zero or many times.
505
506 Unlike expectations, stubs are never verified.
507
508 If expectations and stubs are defined for the same function
509 and arity, the stub is invoked only after all expectations are
510 fulfilled.
511
512 ## Examples
513
514 To allow `MockWeatherAPI.get_temp/1` to be called any number of times:
515
516 stub(MockWeatherAPI, :get_temp, fn _loc -> {:ok, 30} end)
517
518 `stub/3` will overwrite any previous calls to `stub/3`.
519 """
520 def stub(mock, name, code)
521 when is_atom(mock) and is_atom(name) and is_function(code) do
522 add_expectation!(mock, name, code, {0, [], code})
523 mock
524 end
525
526 @doc """
527 Stubs all functions described by the shared behaviours in the `mock` and `module`.
528
529 ## Examples
530
531 defmodule MyApp.WeatherAPI do
532 @callback temp(MyApp.LatLong.t()) :: {:ok, integer()}
533 @callback humidity(MyApp.LatLong.t()) :: {:ok, integer()}
534 end
535
536 defmodule MyApp.StubWeatherAPI do
537 @behaviour WeatherAPI
538 def temp(_loc), do: {:ok, 30}
539 def humidity(_loc), do: {:ok, 60}
540 end
541
542 defmock(MyApp.MockWeatherAPI, for: MyApp.WeatherAPI)
543 stub_with(MyApp.MockWeatherAPI, MyApp.StubWeatherAPI)
544
545 This is the same as calling `stub/3` for each callback in `MyApp.MockWeatherAPI`:
546
547 stub(MyApp.MockWeatherAPI, :temp, &MyApp.StubWeatherAPI.temp/1)
548 stub(MyApp.MockWeatherAPI, :humidity, &MyApp.StubWeatherAPI.humidity/1)
549
550 """
551 def stub_with(mock, module) when is_atom(mock) and is_atom(module) do
552 mock_behaviours = mock.__mock_for__()
553
554 behaviours =
555 case module_behaviours(module) do
556 [] ->
557 raise ArgumentError, "#{inspect(module)} does not implement any behaviour"
558
559 behaviours ->
560 case Enum.filter(behaviours, &(&1 in mock_behaviours)) do
561 [] ->
562 raise ArgumentError,
563 "#{inspect(module)} and #{inspect(mock)} do not share any behaviour"
564
565 common ->
566 common
567 end
568 end
569
570 for behaviour <- behaviours,
571 {fun, arity} <- behaviour.behaviour_info(:callbacks),
572 function_exported?(mock, fun, arity) do
573 stub(mock, fun, :erlang.make_fun(module, fun, arity))
574 end
575
576 mock
577 end
578
579 defp module_behaviours(module) do
580 module.module_info(:attributes)
581 |> Keyword.get_values(:behaviour)
582 |> List.flatten()
583 end
584
585 defp add_expectation!(mock, name, code, value) do
586 validate_mock!(mock)
587 arity = :erlang.fun_info(code)[:arity]
588 key = {mock, name, arity}
589
590 unless function_exported?(mock, name, arity) do
591 raise ArgumentError, "unknown function #{name}/#{arity} for mock #{inspect(mock)}"
592 end
593
594 case Mox.Server.add_expectation(self(), key, value) do
595 :ok ->
596 :ok
597
598 {:error, {:currently_allowed, owner_pid}} ->
599 inspected = inspect(self())
600
601 raise ArgumentError, """
602 cannot add expectations/stubs to #{inspect(mock)} in the current process (#{inspected}) \
603 because the process has been allowed by #{inspect(owner_pid)}. \
604 You cannot define expectations/stubs in a process that has been allowed
605 """
606
607 {:error, {:not_global_owner, global_pid}} ->
608 inspected = inspect(self())
609
610 raise ArgumentError, """
611 cannot add expectations/stubs to #{inspect(mock)} in the current process (#{inspected}) \
612 because Mox is in global mode and the global process is #{inspect(global_pid)}. \
613 Only the process that set Mox to global can set expectations/stubs in global mode
614 """
615 end
616 end
617
618 @doc """
619 Allows other processes to share expectations and stubs
620 defined by owner process.
621
622 ## Examples
623
624 To allow `child_pid` to call any stubs or expectations defined for `MyMock`:
625
626 allow(MyMock, self(), child_pid)
627
628 `allow/3` also accepts named process or via references:
629
630 allow(MyMock, self(), SomeChildProcess)
631
632 """
633 def allow(mock, owner_pid, allowed_via) when is_atom(mock) and is_pid(owner_pid) do
634 allowed_pid = GenServer.whereis(allowed_via)
635
636 if allowed_pid == owner_pid do
637 raise ArgumentError, "owner_pid and allowed_pid must be different"
638 end
639
640 case Mox.Server.allow(mock, owner_pid, allowed_pid) do
641 :ok ->
642 mock
643
644 {:error, {:already_allowed, actual_pid}} ->
645 raise ArgumentError, """
646 cannot allow #{inspect(allowed_pid)} to use #{inspect(mock)} from #{inspect(owner_pid)} \
647 because it is already allowed by #{inspect(actual_pid)}.
648
649 If you are seeing this error message, it is because you are either \
650 setting up allowances from different processes or your tests have \
651 async: true and you found a race condition where two different tests \
652 are allowing the same process
653 """
654
655 {:error, :expectations_defined} ->
656 raise ArgumentError, """
657 cannot allow #{inspect(allowed_pid)} to use #{inspect(mock)} from #{inspect(owner_pid)} \
658 because the process has already defined its own expectations/stubs
659 """
660
661 {:error, :in_global_mode} ->
662 # Already allowed
663 mock
664 end
665 end
666
667 @doc """
668 Verifies the current process after it exits.
669
670 If you want to verify expectations for all tests, you can use
671 `verify_on_exit!/1` as a setup callback:
672
673 setup :verify_on_exit!
674
675 """
676 def verify_on_exit!(_context \\ %{}) do
677 pid = self()
678 Mox.Server.verify_on_exit(pid)
679
680 ExUnit.Callbacks.on_exit(Mox, fn ->
681 verify_mock_or_all!(pid, :all, :on_exit)
682 end)
683 end
684
685 @doc """
686 Verifies that all expectations set by the current process
687 have been called.
688 """
689 def verify! do
690 verify_mock_or_all!(self(), :all, :test)
691 end
692
693 @doc """
694 Verifies that all expectations in `mock` have been called.
695 """
696 def verify!(mock) do
697 validate_mock!(mock)
698 verify_mock_or_all!(self(), mock, :test)
699 end
700
701 defp verify_mock_or_all!(pid, mock, test_or_on_exit) do
702 pending = Mox.Server.verify(pid, mock, test_or_on_exit)
703
704 messages =
705 for {{module, name, arity}, total, pending} <- pending do
706 mfa = Exception.format_mfa(module, name, arity)
707 called = total - pending
708 " * expected #{mfa} to be invoked #{times(total)} but it was invoked #{times(called)}"
709 end
710
711 if messages != [] do
712 raise VerificationError,
713 "error while verifying mocks for #{inspect(pid)}:\n\n" <> Enum.join(messages, "\n")
714 end
715
716 :ok
717 end
718
719 defp validate_mock!(mock) do
720 cond do
721 Code.ensure_compiled(mock) != {:module, mock} ->
722 raise ArgumentError, "module #{inspect(mock)} is not available"
723
724 not function_exported?(mock, :__mock_for__, 0) ->
725 raise ArgumentError, "module #{inspect(mock)} is not a mock"
726
727 true ->
728 :ok
729 end
730 end
731
732 @doc false
733 def __dispatch__(mock, name, arity, args) do
734 all_callers = [self() | caller_pids()]
735
736 case Mox.Server.fetch_fun_to_dispatch(all_callers, {mock, name, arity}) do
737 :no_expectation ->
738 mfa = Exception.format_mfa(mock, name, arity)
739
740 raise UnexpectedCallError,
741 "no expectation defined for #{mfa} in #{format_process()} with args #{inspect(args)}"
742
743 {:out_of_expectations, count} ->
744 mfa = Exception.format_mfa(mock, name, arity)
745
746 raise UnexpectedCallError,
747 "expected #{mfa} to be called #{times(count)} but it has been " <>
748 "called #{times(count + 1)} in #{format_process()}"
749
750 {:ok, fun_to_call} ->
751 apply(fun_to_call, args)
752 end
753 end
754
755 defp times(1), do: "once"
756 defp times(n), do: "#{n} times"
757
758 defp format_process do
759 callers = caller_pids()
760
761 "process #{inspect(self())}" <>
762 if Enum.empty?(callers) do
763 ""
764 else
765 " (or in its callers #{inspect(callers)})"
766 end
767 end
768
769 # Find the pid of the actual caller
770 defp caller_pids do
771 case Process.get(:"$callers") do
772 nil -> []
773 pids when is_list(pids) -> pids
774 end
775 end
776 end

lib/hexpm/accounts/audit_log.ex

97.8
91
5167
2
Line Hits Source
0 defmodule Hexpm.Accounts.AuditLog do
1 use Hexpm.Schema
2
3 1730 schema "audit_logs" do
4 field :user_agent, :string
5 field :remote_ip, :string
6 field :action, :string
7 field :params, :map
8
9 belongs_to :user, User
10 belongs_to :organization, Organization
11
12 timestamps(updated_at: false)
13 end
14
15 def build(nil, user_agent, remote_ip, action, params)
16 when action in ~w(password.reset.init password.reset.finish) do
17 6 params = extract_params(action, params)
18
19 6 %AuditLog{
20 user_id: nil,
21 organization_id: nil,
22 user_agent: user_agent,
23 remote_ip: remote_ip,
24 action: action,
25 params: params
26 }
27 end
28
29 def build(%User{id: user_id}, user_agent, remote_ip, "organization.create", organization) do
30 2 params = extract_params("organization.create", organization)
31
32 2 %AuditLog{
33 user_id: user_id,
34 2 organization_id: organization.id,
35 user_agent: user_agent,
36 remote_ip: remote_ip,
37 action: "organization.create",
38 params: params
39 }
40 end
41
42 def build(%User{id: user_id}, user_agent, remote_ip, action, params) do
43 209 params = extract_params(action, params)
44
45 209 %AuditLog{
46 user_id: user_id,
47 209 organization_id: params[:organization][:id] || params[:package][:organization_id],
48 user_agent: user_agent,
49 remote_ip: remote_ip,
50 action: action,
51 params: params
52 }
53 end
54
55 def build(%Organization{id: organization_id}, user_agent, remote_ip, action, params) do
56 4 params = extract_params(action, params)
57
58 4 %AuditLog{
59 user_id: nil,
60 organization_id: organization_id,
61 user_agent: user_agent,
62 remote_ip: remote_ip,
63 action: action,
64 params: params
65 }
66 end
67
68 def audit({user, user_agent, remote_ip}, action, params) do
69 31 build(user, user_agent, remote_ip, action, params)
70 end
71
72 def audit(multi, nil, _action, _fun) do
73 183 multi
74 end
75
76 def audit(multi, {user, user_agent, remote_ip}, action, fun) when is_function(fun, 1) do
77 128 Multi.merge(multi, fn data ->
78 102 Multi.insert(
79 Multi.new(),
80 multi_key(multi, action),
81 build(user, user_agent, remote_ip, action, fun.(data))
82 )
83 end)
84 end
85
86 def audit(multi, {user, user_agent, remote_ip}, action, params) do
87 58 Multi.insert(
88 multi,
89 multi_key(multi, action),
90 build(user, user_agent, remote_ip, action, params)
91 )
92 end
93
94 2 def audit_many(multi, who, action, list, opts \\ [])
95
96 def audit_many(multi, nil, _action, _list, _opts) do
97 0 multi
98 end
99
100 def audit_many(multi, {user, user_agent, remote_ip}, action, list, opts) do
101 2 fields = AuditLog.__schema__(:fields) -- [:id]
102 2 extra = %{inserted_at: DateTime.utc_now()}
103
104 2 entries =
105 Enum.map(list, fn entry ->
106 build(user, user_agent, remote_ip, action, entry)
107 |> Map.take(fields)
108 4 |> Map.merge(extra)
109 end)
110
111 2 Multi.insert_all(multi, multi_key(multi, action), AuditLog, entries, opts)
112 end
113
114 def audit_with_user(multi, nil, _action, _fun) do
115 0 multi
116 end
117
118 def audit_with_user(multi, {_user, user_agent, remote_ip}, action, fun) do
119 25 Multi.insert(multi, multi_key(multi, action), fn %{user: user} = data ->
120 17 build(user, user_agent, remote_ip, action, fun.(data))
121 end)
122 end
123
124 defp extract_params("docs.publish", {package, release}),
125 8 do: %{package: serialize(package), release: serialize(release)}
126
127 defp extract_params("docs.revert", {package, release}),
128 3 do: %{package: serialize(package), release: serialize(release)}
129
130 7 defp extract_params("key.generate", key), do: serialize(key)
131 8 defp extract_params("key.remove", key), do: serialize(key)
132
133 defp extract_params("owner.add", {package, level, user}),
134 9 do: %{package: serialize(package), level: level, user: serialize(user)}
135
136 defp extract_params("owner.transfer", {package, level, user}),
137 2 do: %{package: serialize(package), level: level, user: serialize(user)}
138
139 defp extract_params("owner.remove", {package, level, user}),
140 3 do: %{package: serialize(package), level: level, user: serialize(user)}
141
142 defp extract_params("release.publish", {package, release}),
143 28 do: %{package: serialize(package), release: serialize(release)}
144
145 defp extract_params("release.revert", {package, release}),
146 11 do: %{package: serialize(package), release: serialize(release)}
147
148 defp extract_params("release.retire", {package, release}),
149 3 do: %{package: serialize(package), release: serialize(release)}
150
151 defp extract_params("release.unretire", {package, release}),
152 3 do: %{package: serialize(package), release: serialize(release)}
153
154 defp extract_params("email.add", {organization, email}),
155 4 do: Map.merge(%{organization: serialize(organization)}, serialize(email))
156
157 18 defp extract_params("email.add", email), do: serialize(email)
158
159 defp extract_params("email.remove", {organization, email}),
160 2 do: Map.merge(%{organization: serialize(organization)}, serialize(email))
161
162 1 defp extract_params("email.remove", email), do: serialize(email)
163
164 defp extract_params("email.primary", {old_email, new_email}),
165 6 do: %{old_email: serialize(old_email), new_email: serialize(new_email)}
166
167 defp extract_params("email.public", {organization, {old_email, new_email}}),
168 3 do: %{
169 organization: serialize(organization),
170 old_email: serialize(old_email),
171 new_email: serialize(new_email)
172 }
173
174 defp extract_params("email.public", {old_email, new_email}),
175 8 do: %{old_email: serialize(old_email), new_email: serialize(new_email)}
176
177 defp extract_params("email.gravatar", {organization, {old_email, new_email}}),
178 3 do: %{
179 organization: serialize(organization),
180 old_email: serialize(old_email),
181 new_email: serialize(new_email)
182 }
183
184 defp extract_params("email.gravatar", {old_email, new_email}),
185 2 do: %{old_email: serialize(old_email), new_email: serialize(new_email)}
186
187 5 defp extract_params("user.create", user), do: serialize(user)
188 18 defp extract_params("user.update", user), do: serialize(user)
189 4 defp extract_params("security.update", user), do: serialize(user)
190 1 defp extract_params("security.rotate_recovery_codes", user), do: serialize(user)
191 2 defp extract_params("organization.create", organization), do: serialize(organization)
192
193 defp extract_params("organization.member.add", {organization, user}),
194 3 do: %{organization: serialize(organization), user: serialize(user)}
195
196 defp extract_params("organization.member.remove", {organization, user}),
197 5 do: %{organization: serialize(organization), user: serialize(user)}
198
199 defp extract_params("organization.member.role", {organization, user, role}),
200 2 do: %{organization: serialize(organization), user: serialize(user), role: role}
201
202 8 defp extract_params("password.reset.init", nil), do: %{}
203 2 defp extract_params("password.reset.finish", nil), do: %{}
204 4 defp extract_params("password.update", nil), do: %{}
205
206 defp extract_params("billing.checkout", {organization, data}),
207 5 do: %{
208 organization: serialize(organization),
209 payment_source: data[:payment_source]
210 }
211
212 defp extract_params("billing.cancel", {organization, _params}),
213 6 do: %{organization: serialize(organization)}
214
215 defp extract_params("billing.create", {organization, params}),
216 5 do: %{
217 organization: serialize(organization),
218 email: params["email"],
219 person: params["person"],
220 company: params["company"],
221 token: params["token"],
222 quantity: params["quantity"]
223 }
224
225 defp extract_params("billing.update", {organization, params}),
226 9 do: %{
227 organization: serialize(organization),
228 email: params["email"],
229 person: params["person"],
230 company: params["company"],
231 token: params["token"],
232 quantity: params["quantity"]
233 }
234
235 defp extract_params("billing.change_plan", {organization, params}),
236 5 do: %{
237 organization: serialize(organization),
238 plan_id: params["plan_id"]
239 }
240
241 defp extract_params("billing.pay_invoice", {organization, invoice_id}),
242 5 do: %{
243 organization: serialize(organization),
244 invoice_id: invoice_id
245 }
246
247 defp serialize(%Key{} = key) do
248 key
249 |> do_serialize()
250 15 |> Map.put(:permissions, Enum.map(key.permissions, &serialize/1))
251 15 |> Map.put(:user, serialize(key.user))
252 15 |> Map.put(:organization, serialize(key.organization))
253 end
254
255 defp serialize(%Package{} = package) do
256 package
257 |> do_serialize()
258 70 |> Map.put(:meta, serialize(package.meta))
259 end
260
261 defp serialize(%Release{} = release) do
262 release
263 |> do_serialize()
264 56 |> Map.put(:meta, serialize(release.meta))
265 56 |> Map.put(:retirement, serialize(release.retirement))
266 end
267
268 defp serialize(%User{} = user) do
269 user
270 |> do_serialize()
271 62 |> Map.put(:handles, serialize(user.handles))
272 end
273
274 115 defp serialize(nil), do: nil
275 292 defp serialize(schema), do: do_serialize(schema)
276
277 495 defp do_serialize(schema), do: Map.take(schema, fields(schema))
278
279 55 defp fields(%Email{}), do: [:email, :primary, :public, :primary, :gravatar]
280 15 defp fields(%Key{}), do: [:id, :name]
281 15 defp fields(%KeyPermission{}), do: [:resource, :domain]
282 70 defp fields(%Package{}), do: [:id, :name, :organization_id]
283 70 defp fields(%PackageMetadata{}), do: [:description, :licenses, :links, :maintainers, :extra]
284 56 defp fields(%Release{}), do: [:id, :version, :checksum, :has_docs, :package_id]
285 56 defp fields(%ReleaseMetadata{}), do: [:app, :build_tools, :elixir]
286 3 defp fields(%ReleaseRetirement{}), do: [:status, :message]
287 49 defp fields(%Organization{}), do: [:id, :name, :public, :active, :billing_active]
288 62 defp fields(%User{}), do: [:id, :username]
289 44 defp fields(%UserHandles{}), do: [:github, :twitter, :freenode]
290
291 defp multi_key(multi, action) do
292 187 :"log.#{action}.#{length(Multi.to_list(multi))}"
293 end
294
295 def count_by(schema) do
296 5 from(l in all_by(schema), select: count(l))
297 end
298
299 def all_by(%Hexpm.Repository.Package{} = package) do
300 18 from(l in AuditLog,
301 18 where: fragment("(? -> 'package' ->> 'id')::integer", l.params) == ^package.id
302 )
303 end
304
305 def all_by(%Hexpm.Accounts.Organization{} = organization) do
306 3 Ecto.assoc(organization, :audit_logs)
307 end
308
309 def all_by(%Hexpm.Accounts.User{} = user) do
310 36 Ecto.assoc(user, :audit_logs)
311 end
312
313 def newest_first(query) do
314 52 Ecto.Query.order_by(query, desc: :inserted_at)
315 end
316 end

lib/hexpm/accounts/audit_logs.ex

100
3
57
0
Line Hits Source
0 defmodule Hexpm.Accounts.AuditLogs do
1 use Hexpm.Context
2
3 alias Hexpm.Accounts.AuditLog
4
5 def all_by(schema) do
6 AuditLog.all_by(schema)
7 |> AuditLog.newest_first()
8 33 |> Repo.all()
9 end
10
11 def all_by(schema, page, per_page) do
12 AuditLog.all_by(schema)
13 |> AuditLog.newest_first()
14 |> Hexpm.Utils.paginate(page, per_page)
15 19 |> Repo.all()
16 end
17
18 @doc """
19 Return the number of audit_logs belong to the schema (user/organization/package)
20 """
21 def count_by(schema) do
22 AuditLog.count_by(schema)
23 5 |> Repo.one()
24 end
25 end

lib/hexpm/accounts/auth.ex

96.2
26
4208
1
Line Hits Source
0 defmodule Hexpm.Accounts.Auth do
1 import Ecto.Query, only: [from: 2]
2
3 alias Hexpm.Accounts.{Key, Keys, User, Users}
4
5 def key_auth(user_secret, usage_info) do
6 # Database index lookup on the first part of the key and then
7 # secure compare on the second part to avoid timing attacks
8 248 app_secret = Application.get_env(:hexpm, :secret)
9
10 248 <<first::binary-size(32), second::binary-size(32)>> =
11 :crypto.mac(:hmac, :sha256, app_secret, user_secret)
12 |> Base.encode16(case: :lower)
13
14 248 result =
15 from(
16 k in Key,
17 where: k.secret_first == ^first,
18 left_join: u in assoc(k, :user),
19 left_join: o in assoc(k, :organization),
20 preload: [user: {u, [:emails, owned_packages: :repository, organizations: :repository]}],
21 preload: [organization: {o, [:repository, :user]}]
22 )
23 |> Hexpm.Repo.one()
24
25 248 case result do
26 7 nil ->
27 :error
28
29 key ->
30 241 valid_auth = !key.user || !User.organization?(key.user)
31
32 241 if valid_auth && Hexpm.Utils.secure_check(key.secret_second, second) do
33 241 if Key.revoked?(key) do
34 :revoked
35 else
36 236 Keys.update_last_use(key, usage_info(usage_info))
37
38 {:ok,
39 %{
40 key: key,
41 236 user: key.user,
42 236 organization: key.organization,
43 236 email: find_email(key.user, nil),
44 source: :key
45 }}
46 end
47 else
48 :error
49 end
50 end
51 end
52
53 def password_auth(username_or_email, password) do
54 32 user =
55 Users.get(username_or_email, [
56 :emails,
57 owned_packages: :repository,
58 organizations: :repository
59 ])
60
61 32 valid_user = user && !User.organization?(user) && user.password
62
63 32 if valid_user && Bcrypt.verify_pass(password, user.password) do
64 {:ok,
65 %{
66 key: nil,
67 user: user,
68 organization: nil,
69 email: find_email(user, username_or_email),
70 source: :password
71 }}
72 else
73 :error
74 end
75 end
76
77 0 def gen_password(nil), do: nil
78 44 def gen_password(password), do: Bcrypt.hash_pwd_salt(password)
79
80 def gen_key() do
81 :crypto.strong_rand_bytes(16)
82 434 |> Base.encode16(case: :lower)
83 end
84
85 27 defp find_email(nil, _email) do
86 nil
87 end
88
89 defp find_email(user, email) do
90 233 Enum.find(user.emails, &(&1.email == email)) || Enum.find(user.emails, & &1.primary)
91 end
92
93 defp usage_info(info) do
94 236 %{
95 ip: parse_ip(info[:ip]),
96 used_at: info[:used_at],
97 user_agent: parse_user_agent(info[:user_agent])
98 }
99 end
100
101 2 defp parse_ip(nil), do: nil
102
103 defp parse_ip(ip_tuple) do
104 ip_tuple
105 |> Tuple.to_list()
106 234 |> Enum.join(".")
107 end
108
109 2 defp parse_user_agent(nil), do: nil
110 233 defp parse_user_agent([]), do: nil
111 1 defp parse_user_agent([value | _]), do: value
112 end

lib/hexpm/accounts/email.ex

93.8
16
7201
1
Line Hits Source
0 defmodule Hexpm.Accounts.Email do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @email_regex ~r"^.+@.+\..+$"
5
6 7067 schema "emails" do
7 field :email, :string
8 field :verified, :boolean, default: false
9 field :primary, :boolean, default: false
10 field :public, :boolean, default: false
11 field :gravatar, :boolean, default: false
12 field :verification_key, :string
13 field :verification_expiry, :utc_datetime_usec
14
15 belongs_to :user, User
16
17 timestamps()
18 end
19
20 15 def changeset(email, type, params, verified? \\ not Application.get_env(:hexpm, :user_confirm))
21
22 def changeset(email, :first, params, verified?) do
23 changeset(email, :create, params, verified?)
24 |> put_change(:primary, true)
25 9 |> put_change(:public, true)
26 end
27
28 def changeset(email, :create, params, verified?) do
29 cast(email, params, ~w(email)a)
30 |> validate_confirmation(:email, message: "does not match email")
31 |> downcase_and_validate_email()
32 |> validate_verified_email_exists(:email, message: "already in use")
33 |> put_change(:verified, verified?)
34 |> put_change(:verification_key, Auth.gen_key())
35 25 |> put_change(:verification_expiry, DateTime.utc_now())
36 end
37
38 def changeset(email, :create_for_org, params, false) do
39 cast(email, params, ~w(email public gravatar)a)
40 |> downcase_and_validate_email()
41 6 |> put_change(:verified, false)
42 end
43
44 def verification(email) do
45 1 change(email, %{
46 verification_key: Auth.gen_key(),
47 verification_expiry: DateTime.utc_now()
48 })
49 end
50
51 0 def verify?(nil, _key), do: false
52
53 def verify?(email, key) do
54 5 email_key = email.verification_key
55 5 valid_key? = !!(email_key && Hexpm.Utils.secure_check(email_key, key))
56 5 within_time? = Hexpm.Utils.within_last_day?(email.verification_expiry)
57 5 valid_key? and within_time?
58 end
59
60 def verify(email) do
61 change(email, %{
62 verified: true,
63 verification_key: nil,
64 verification_expiry: nil
65 })
66 3 |> unique_constraint(:email, name: "emails_email_key", message: "already in use")
67 end
68
69 def update_email(email, new_address) do
70 change(email, %{email: new_address})
71 3 |> downcase_and_validate_email()
72 end
73
74 def toggle_flag(email, flag, value) do
75 15 change(email, %{flag => value})
76 end
77
78 def order_emails(emails) do
79 3 Enum.sort_by(emails, &[not &1.primary, not &1.public, not &1.verified, -&1.id])
80 end
81
82 defp downcase_and_validate_email(changeset) do
83 changeset
84 |> validate_required(~w(email)a)
85 |> update_change(:email, &String.downcase/1)
86 |> validate_format(:email, @email_regex)
87 |> unique_constraint(:email, name: "emails_email_key")
88 34 |> unique_constraint(:email, name: "emails_email_user_key")
89 end
90 end

lib/hexpm/accounts/key.ex

96.5
57
8771
2
Line Hits Source
0 defmodule Hexpm.Accounts.Key do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @derive {Phoenix.Param, key: :name}
5
6 @days_30 60 * 60 * 24 * 30
7
8 2223 schema "keys" do
9 field :name, :string
10 field :secret_first, :string
11 field :secret_second, :string
12 field :public, :boolean, default: true
13 field :revoke_at, :utc_datetime_usec
14 field :revoked_at, :utc_datetime_usec
15 timestamps()
16
17 256 embeds_one :last_use, Use, on_replace: :delete do
18 field :used_at, :utc_datetime_usec
19 field :user_agent, :string
20 field :ip, :string
21 end
22
23 belongs_to :user, User
24 belongs_to :organization, Organization
25 embeds_many :permissions, KeyPermission
26
27 # Only used after key creation to hold the user's key (not hashed)
28 # the user key will never be retrievable after this
29 field :user_secret, :string, virtual: true
30 end
31
32 def changeset(key, user_or_organization, params) do
33 cast(key, params, ~w(name)a)
34 |> validate_required(~w(name)a)
35 |> add_keys()
36 |> prepare_changes(&unique_name/1)
37 |> unique_constraint(:name, name: "_name_revoked_at_key", match: :suffix)
38 4 |> cast_embed(:permissions, with: &KeyPermission.changeset(&1, user_or_organization, &2))
39 231 |> put_default_embed(:permissions, [%KeyPermission{domain: "api"}])
40 end
41
42 def build(user_or_organization, params) do
43 build_assoc(user_or_organization, :keys)
44 |> associate_owner(user_or_organization)
45 218 |> changeset(user_or_organization, params)
46 end
47
48 def build_for_docs(user, organization) do
49 3 permission =
50 KeyPermission.changeset(%KeyPermission{}, user, %{
51 "domain" => "docs",
52 "resource" => organization
53 })
54
55 3 revoke_at =
56 NaiveDateTime.add(NaiveDateTime.utc_now(), @days_30) |> DateTime.from_naive!("Etc/UTC")
57
58 build_assoc(user, :keys)
59 |> change()
60 |> add_keys()
61 |> put_change(:revoke_at, revoke_at)
62 |> put_change(:public, false)
63 3 |> put_embed(:permissions, [permission])
64 end
65
66 defmacrop query_revoked(key) do
67 quote do
68 not is_nil(unquote(key).revoked_at) or
69 (not is_nil(unquote(key).revoke_at) and unquote(key).revoke_at < fragment("NOW()"))
70 end
71 end
72
73 def all(user_or_organization) do
74 21 from(
75 k in assoc(user_or_organization, :keys),
76 where: not query_revoked(k),
77 where: k.public
78 )
79 end
80
81 def get(user_or_organization, name) do
82 19 from(
83 k in assoc(user_or_organization, :keys),
84 where: k.name == ^name,
85 where: not query_revoked(k)
86 )
87 end
88
89 def get_revoked(user_or_organization, name) do
90 4 from(
91 k in assoc(user_or_organization, :keys),
92 where: k.name == ^name,
93 where: query_revoked(k)
94 )
95 end
96
97 def revoke(key, revoked_at \\ DateTime.utc_now()) do
98 key
99 |> change()
100 4 |> put_change(:revoked_at, key.revoked_at || revoked_at)
101 4 |> validate_required(:revoked_at)
102 end
103
104 def revoke_by_name(user_or_organization, key_name, revoked_at \\ DateTime.utc_now()) do
105 0 from(
106 k in assoc(user_or_organization, :keys),
107 where: k.name == ^key_name and not query_revoked(k),
108 update: [
109 set: [
110 revoked_at: ^revoked_at,
111 updated_at: ^DateTime.utc_now()
112 ]
113 ]
114 )
115 end
116
117 def revoke_all(user_or_organization, revoked_at \\ DateTime.utc_now()) do
118 2 from(
119 k in assoc(user_or_organization, :keys),
120 where: not query_revoked(k),
121 update: [
122 set: [
123 revoked_at: ^revoked_at,
124 updated_at: ^DateTime.utc_now()
125 ]
126 ]
127 )
128 end
129
130 def gen_key() do
131 398 user_secret = Auth.gen_key()
132 398 app_secret = Application.get_env(:hexpm, :secret)
133
134 398 <<first::binary-size(32), second::binary-size(32)>> =
135 :crypto.mac(:hmac, :sha256, app_secret, user_secret)
136 |> Base.encode16(case: :lower)
137
138 398 {user_secret, first, second}
139 end
140
141 def update_last_use(key, params) do
142 key
143 |> change()
144 233 |> put_embed(:last_use, struct(Key.Use, params))
145 end
146
147 defp add_keys(changeset) do
148 234 {user_secret, first, second} = gen_key()
149
150 changeset
151 |> put_change(:user_secret, user_secret)
152 |> put_change(:secret_first, first)
153 234 |> put_change(:secret_second, second)
154 end
155
156 defp unique_name(changeset) do
157 216 {:ok, name} = fetch_change(changeset, :name)
158
159 216 source =
160 216 if changeset.data.organization_id do
161 15 assoc(changeset.data, :organization)
162 else
163 201 assoc(changeset.data, :user)
164 end
165
166 216 names =
167 216 from(
168 s in source,
169 join: k in assoc(s, :keys),
170 where: not query_revoked(k),
171 where: k.name == ^name or like(k.name, ^(name <> "-%")),
172 select: k.name
173 )
174 216 |> changeset.repo.all
175
176 216 name = if name in names, do: find_unique_name(name, names), else: name
177
178 216 put_change(changeset, :name, name)
179 end
180
181 defp find_unique_name(name, names) do
182 16 max =
183 names
184 |> Enum.flat_map(fn existing_name ->
185 21 case Integer.parse(String.trim_leading(existing_name, name <> "-")) do
186 1 {num, ""} -> [num]
187 20 _ -> []
188 end
189 end)
190 15 |> Enum.max(&>=/2, fn -> 1 end)
191
192 16 "#{name}-#{max + 1}"
193 end
194
195 def verify_permissions?(key, "api", resource) do
196 216 Enum.any?(key.permissions, fn permission ->
197 216 permission.domain == "api" and match_api_resource?(permission.resource, resource)
198 end)
199 end
200
201 def verify_permissions?(key, "repositories", _resource) do
202 7 Enum.any?(key.permissions, &(&1.domain == "repositories"))
203 end
204
205 def verify_permissions?(key, "repository", resource) do
206 24 Enum.any?(key.permissions, fn permission ->
207 32 (permission.domain == "repository" and permission.resource == resource) or
208 26 permission.domain == "repositories"
209 end)
210 end
211
212 def verify_permissions?(key, "docs", resource) do
213 4 Enum.any?(key.permissions, fn permission ->
214 4 permission.domain == "docs" and permission.resource == resource
215 end)
216 end
217
218 0 def verify_permissions?(_key, nil, _resource) do
219 false
220 end
221
222 199 defp match_api_resource?(nil, _resource), do: true
223 3 defp match_api_resource?("write", "write"), do: true
224 1 defp match_api_resource?("write", "read"), do: true
225 3 defp match_api_resource?("read", "read"), do: true
226 3 defp match_api_resource?(_key_resource, _resource), do: false
227
228 def revoked?(%Key{} = key) do
229 241 not is_nil(key.revoked_at) or
230 237 (not is_nil(key.revoke_at) and DateTime.compare(key.revoke_at, DateTime.utc_now()) == :lt)
231 end
232
233 2 def associate_owner(nil, _owner), do: nil
234 212 def associate_owner(%Key{} = key, %User{} = user), do: %{key | user: user, organization: nil}
235
236 def associate_owner(%Key{} = key, %Organization{} = organization),
237 20 do: %{key | user: nil, organization: organization}
238 end

lib/hexpm/accounts/key_permission.ex

76.5
17
866
4
Line Hits Source
0 defmodule Hexpm.Accounts.KeyPermission do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @domains ~w(api repository repositories docs)
5
6 785 embedded_schema do
7 field :domain, :string
8 field :resource, :string
9 end
10
11 def changeset(struct, user_or_organization, params) do
12 cast(struct, params, ~w(domain resource)a)
13 |> validate_inclusion(:domain, @domains)
14 |> validate_resource()
15 7 |> validate_permission(user_or_organization)
16 end
17
18 defp validate_permission(changeset, user_or_organization) do
19 7 validate_change(changeset, :resource, fn _, resource ->
20 6 domain = get_change(changeset, :domain)
21
22 6 case verify_permissions(user_or_organization, domain, resource) do
23 4 {:ok, _} ->
24 []
25
26 2 :error ->
27 # NOTE: Possibly change repository if we add more domains
28 [resource: "you do not have access to this repository"]
29 end
30 end)
31 end
32
33 defp validate_resource(changeset) do
34 7 validate_change(changeset, :resource, fn _, resource ->
35 6 case get_change(changeset, :domain) do
36 0 nil -> []
37 0 "api" when resource in [nil, "read", "write"] -> []
38 3 "repository" when is_binary(resource) -> []
39 3 "docs" when is_binary(resource) -> []
40 0 "repositories" when is_nil(resource) -> []
41 0 _ -> [resource: "invalid resource for given domain"]
42 end
43 end)
44 end
45
46 def verify_permissions(%User{} = user, domain, resource),
47 22 do: User.verify_permissions(user, domain, resource)
48
49 def verify_permissions(%Organization{} = organization, domain, resource),
50 8 do: Organization.verify_permissions(organization, domain, resource)
51 end

lib/hexpm/accounts/keys.ex

87.5
16
900
2
Line Hits Source
0 defmodule Hexpm.Accounts.Keys do
1 use Hexpm.Context
2
3 def all(user_or_organization) do
4 Key.all(user_or_organization)
5 |> Repo.all()
6 18 |> Enum.map(&Key.associate_owner(&1, user_or_organization))
7 end
8
9 def get(id) do
10 Repo.get(Key, id)
11 1 |> Repo.preload([:organization, :user])
12 end
13
14 def get(user_or_organization, name) do
15 Repo.one(Key.get(user_or_organization, name))
16 7 |> Key.associate_owner(user_or_organization)
17 end
18
19 def create(user_or_organization, params, audit: audit_data) do
20 Multi.new()
21 |> Multi.insert(:key, Key.build(user_or_organization, params))
22 6 |> audit(audit_data, "key.generate", fn %{key: key} -> key end)
23 |> Repo.transaction()
24 191 |> maybe_retry_for_unique_name(fn ->
25 0 create(user_or_organization, params, audit: audit_data)
26 end)
27 end
28
29 def create_for_docs(user, organization) do
30 Key.build_for_docs(user, organization)
31 3 |> Repo.insert()
32 end
33
34 def revoke(key, audit: audit_data) do
35 Multi.new()
36 |> Multi.update(:key, Key.revoke(key))
37 |> audit(audit_data, "key.remove", key)
38 4 |> Repo.transaction()
39 end
40
41 def revoke(user_or_organization, name, audit: audit_data) do
42 6 if key = get(user_or_organization, name) do
43 4 revoke(key, audit: audit_data)
44 else
45 {:error, :not_found}
46 end
47 end
48
49 def revoke_all(user_or_organization, audit: audit_data) do
50 Multi.new()
51 |> Multi.update_all(:keys, Key.revoke_all(user_or_organization), [])
52 |> audit_many(audit_data, "key.remove", all(user_or_organization))
53 1 |> Repo.transaction()
54 end
55
56 def update_last_use(%Key{public: true} = key, usage_info) do
57 235 if Repo.write_mode?() do
58 key
59 |> Key.update_last_use(usage_info)
60 233 |> Repo.update!()
61 end
62 end
63
64 def update_last_use(%Key{public: false} = key, _usage_info) do
65 1 key
66 end
67
68 defp maybe_retry_for_unique_name(
69 {:error, :key, %Ecto.Changeset{errors: [{:name, {"has already been taken", _}}]}, _},
70 fun
71 ) do
72 0 fun.()
73 end
74
75 defp maybe_retry_for_unique_name(other, _fun) do
76 190 other
77 end
78 end

lib/hexpm/accounts/organization.ex

90.9
22
3154
2
Line Hits Source
0 defmodule Hexpm.Accounts.Organization do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @derive {Phoenix.Param, key: :name}
5 @month_seconds 31 * 24 * 60 * 60
6
7 2441 schema "organizations" do
8 field :name, :string
9 field :billing_active, :boolean, default: false
10 field :trial_end, :utc_datetime_usec
11 timestamps()
12
13 has_one :repository, Repository
14 has_one :user, User
15 has_many :organization_users, OrganizationUser
16 has_many :users, through: [:organization_users, :user]
17 has_many :keys, Key
18 has_many :audit_logs, AuditLog, foreign_key: :organization_id
19 end
20
21 @name_regex ~r"^[a-z0-9_\-\.]+$"
22 @roles ~w(admin write read)
23
24 @reserved_names ~w(www staging elixir erlang otp rebar rebar3 phoenix acme)
25
26 def changeset(struct, params) do
27 cast(struct, params, ~w(name)a)
28 |> put_change(:trial_end, default_trial_end())
29 |> validate_required(~w(name)a)
30 |> unique_constraint(:name)
31 |> update_change(:name, &String.downcase/1)
32 |> validate_length(:name, min: 3)
33 |> validate_format(:name, @name_regex)
34 2 |> validate_exclusion(:name, @reserved_names)
35 end
36
37 def build_from_user(user) do
38 0 changeset(%Organization{}, %{name: user.username})
39 end
40
41 def add_member(struct, params) do
42 cast(struct, params, ~w(role)a)
43 |> validate_required(~w(role)a)
44 |> validate_inclusion(:role, @roles)
45 15 |> unique_constraint(
46 :user_id,
47 name: "organization_users_organization_id_user_id_index",
48 message: "is already member"
49 )
50 end
51
52 def change_role(struct, params) do
53 cast(struct, params, ~w(role)a)
54 |> validate_required(~w(role)a)
55 2 |> validate_inclusion(:role, @roles)
56 end
57
58 def access(organization, user, role) do
59 84 from(
60 ou in OrganizationUser,
61 84 where: ou.organization_id == ^organization.id,
62 84 where: ou.user_id == ^user.id,
63 where: ou.role in ^role_or_higher(role),
64 select: count(ou.id) >= 1
65 )
66 end
67
68 61 def role_or_higher("read"), do: ["read", "write", "admin"]
69 32 def role_or_higher("write"), do: ["write", "admin"]
70 58 def role_or_higher("admin"), do: ["admin"]
71
72 def hexpm(opts \\ []) do
73 81 repository =
74 if Keyword.get(opts, :recursive, true) do
75 38 Repository.hexpm(recursive: false)
76 else
77 43 %Ecto.Association.NotLoaded{}
78 end
79
80 81 %__MODULE__{
81 id: 1,
82 name: "hexpm",
83 billing_active: true,
84 repository: repository
85 }
86 end
87
88 6 def verify_permissions(%Organization{}, "api", _resource) do
89 {:ok, nil}
90 end
91
92 2 def verify_permissions(%Organization{name: name} = organization, domain, name)
93 when domain in ["repository", "docs"] do
94 {:ok, organization}
95 end
96
97 0 def verify_permissions(%Organization{}, _domain, _resource) do
98 :error
99 end
100
101 def billing_active?(%Organization{billing_active: active} = organization) do
102 31 active or trialing?(organization)
103 end
104
105 def trialing?(%Organization{trial_end: trial_end}) do
106 5 DateTime.compare(trial_end, DateTime.utc_now()) == :gt
107 end
108
109 defp default_trial_end() do
110 DateTime.utc_now()
111 |> DateTime.add(@month_seconds)
112 2 |> to_start_of_day()
113 end
114
115 defp to_start_of_day(%DateTime{} = datetime) do
116 2 %DateTime{datetime | hour: 0, minute: 0, second: 0}
117 end
118 end

lib/hexpm/accounts/organization_user.ex

100
1
2162
0
Line Hits Source
0 defmodule Hexpm.Accounts.OrganizationUser do
1 use Hexpm.Schema
2
3 2162 schema "organization_users" do
4 field :role, :string
5
6 belongs_to :organization, Organization
7 belongs_to :user, User
8
9 timestamps()
10 end
11 end

lib/hexpm/accounts/organizations.ex

79.7
59
432
12
Line Hits Source
0 defmodule Hexpm.Accounts.Organizations do
1 use Hexpm.Context
2
3 def all_by_user(user, preload \\ []) do
4 Repo.all(assoc(user, :organizations))
5 5 |> Repo.preload(preload)
6 end
7
8 def get(name, preload \\ []) do
9 Repo.get_by(Organization, name: name)
10 122 |> Repo.preload(preload)
11 end
12
13 def get_role(organization, user) do
14 40 org_user = Repo.get_by(OrganizationUser, organization_id: organization.id, user_id: user.id)
15 40 org_user && org_user.role
16 end
17
18 def preload(organization, preload) do
19 0 Repo.preload(organization, preload)
20 end
21
22 15 def access?(_organization, nil = _user, _role) do
23 false
24 end
25
26 3 def access?(%Organization{id: id}, %Organization{id: id}, _role) do
27 true
28 end
29
30 def access?(organization, user, role) do
31 84 Repo.one!(Organization.access(organization, user, role))
32 end
33
34 def create(user, params, audit: audit_data) do
35 2 multi =
36 Multi.new()
37 |> Multi.insert(:organization, Organization.changeset(%Organization{}, params))
38 |> Multi.insert(:repository, fn %{organization: organization} ->
39 1 %Repository{name: organization.name, organization_id: organization.id}
40 end)
41 1 |> Multi.insert(:user, &User.build_organization(&1.organization))
42 |> Multi.insert(:organization_user, fn %{organization: organization} ->
43 1 organization_user = %OrganizationUser{
44 1 organization_id: organization.id,
45 1 user_id: user.id,
46 role: "admin"
47 }
48
49 1 Organization.add_member(organization_user, %{})
50 end)
51 1 |> audit(audit_data, "organization.create", & &1.organization)
52
53 2 case Repo.transaction(multi) do
54 1 {:ok, result} -> {:ok, result.organization}
55 0 {:error, :user, changeset, _} -> {:error, changeset}
56 1 {:error, :organization, changeset, _} -> {:error, changeset}
57 end
58 end
59
60 def create_from_user(organization_user, admin_user) do
61 0 multi =
62 Multi.new()
63 |> Multi.insert(:organization, Organization.build_from_user(organization_user))
64 |> Multi.insert(:repository, fn %{organization: organization} ->
65 0 %Repository{name: organization.name, organization_id: organization.id}
66 end)
67 0 |> Multi.update(:user, &User.to_organization(organization_user, &1.organization))
68 |> Multi.insert(:organization_user, fn %{organization: organization} ->
69 0 organization_user = %OrganizationUser{
70 0 organization_id: organization.id,
71 0 user_id: admin_user.id,
72 role: "admin"
73 }
74
75 0 Organization.add_member(organization_user, %{})
76 end)
77
78 0 Repo.transaction(multi)
79 end
80
81 def merge_with_user(
82 %Organization{name: name} = organization,
83 %User{username: name, organization_id: nil} = user
84 ) do
85 0 Repo.update(User.to_organization(user, organization))
86 end
87
88 1 def add_member(_organization, %User{organization_id: id}, _params, _opts) when is_integer(id) do
89 {:error, :organization_user}
90 end
91
92 def add_member(organization, %User{organization_id: nil} = user, params, audit: audit_data) do
93 3 organization_user = %OrganizationUser{organization_id: organization.id, user_id: user.id}
94
95 3 multi =
96 Multi.new()
97 |> Multi.insert(:organization_user, Organization.add_member(organization_user, params))
98 |> audit(audit_data, "organization.member.add", {organization, user})
99
100 3 case Repo.transaction(multi) do
101 {:ok, result} ->
102 2 send_invite_email(organization, user)
103 2 {:ok, result.organization_user}
104
105 1 {:error, :organization_user, changeset, _} ->
106 {:error, changeset}
107 end
108 end
109
110 def remove_member(organization, user, audit: audit_data) do
111 7 count = Repo.aggregate(assoc(organization, :organization_users), :count, :id)
112
113 7 if count == 1 do
114 {:error, :last_member}
115 else
116 5 organization_user = Repo.get_by(assoc(organization, :organization_users), user_id: user.id)
117
118 5 if organization_user do
119 5 {:ok, _result} =
120 Multi.new()
121 |> Multi.delete(:organization_user, organization_user)
122 |> delete_package_owners(organization, user)
123 |> audit(audit_data, "organization.member.remove", {organization, user})
124 |> Repo.transaction()
125 end
126
127 :ok
128 end
129 end
130
131 def change_role(organization, user, params, audit: audit_data) do
132 3 organization_users = Repo.all(assoc(organization, :organization_users))
133 3 organization_user = Enum.find(organization_users, &(&1.user_id == user.id))
134 3 number_admins = Enum.count(organization_users, &(&1.role == "admin"))
135
136 3 cond do
137 3 !organization_user ->
138 {:error, :unknown_user}
139
140 3 organization_user.role == "admin" and number_admins == 1 ->
141 {:error, :last_admin}
142
143 2 true ->
144 2 multi =
145 Multi.new()
146 |> Multi.update(:organization_user, Organization.change_role(organization_user, params))
147 |> audit(audit_data, "organization.member.role", {organization, user, params["role"]})
148
149 2 case Repo.transaction(multi) do
150 2 {:ok, result} ->
151 2 {:ok, result.organization_user}
152
153 0 {:error, :organization_user, changeset, _} ->
154 {:error, changeset}
155 end
156 end
157 end
158
159 def user_count(organization) do
160 16 Repo.aggregate(assoc(organization, :organization_users), :count, :id)
161 end
162
163 defp delete_package_owners(multi, organization, user) do
164 5 Multi.delete_all(multi, :package_owners, fn _changes ->
165 5 from(
166 po in PackageOwner,
167 join: p in assoc(po, :package),
168 join: r in assoc(p, :repository),
169 5 where: r.organization_id == ^organization.id,
170 5 where: po.user_id == ^user.id
171 )
172 end)
173 end
174
175 defp send_invite_email(organization, user) do
176 Emails.organization_invite(organization, user)
177 2 |> Mailer.deliver_later!()
178 end
179 end

lib/hexpm/accounts/password_reset.ex

100
6
1291
0
Line Hits Source
0 defmodule Hexpm.Accounts.PasswordReset do
1 use Hexpm.Schema
2
3 1248 schema "password_resets" do
4 field :key, :string
5 field :primary_email, :string
6 belongs_to :user, User
7
8 timestamps(updated_at: false)
9 end
10
11 def changeset(reset, user) do
12 7 change(reset, %{
13 key: Auth.gen_key(),
14 primary_email: User.email(user, :primary)
15 })
16 end
17
18 def can_reset?(reset, primary_email, key) do
19 9 valid_email? = primary_email == reset.primary_email
20 9 valid_key? = !!(reset.key && Hexpm.Utils.secure_check(reset.key, key))
21 9 within_time? = Hexpm.Utils.within_last_day?(reset.inserted_at)
22
23 9 valid_email? and valid_key? and within_time?
24 end
25 end

lib/hexpm/accounts/recovery_code.ex

77.8
9
166
2
Line Hits Source
0 defmodule Hexpm.Accounts.RecoveryCode do
1 use Hexpm.Schema
2
3 alias Hexpm.Accounts.RecoveryCode
4
5 @derive {Jason.Encoder, only: []}
6
7 @rand_bytes 10
8 @part_size 4
9
10 140 embedded_schema do
11 field :code, :string
12 field :used_at, :utc_datetime_usec
13 end
14
15 def changeset(recovery_code, params) do
16 0 cast(recovery_code, params, [:code, :used_at])
17 end
18
19 def generate_set() do
20 2 Enum.map(1..10, fn _ -> %RecoveryCode{code: generate()} end)
21 end
22
23 def generate() do
24 :crypto.strong_rand_bytes(@rand_bytes)
25 |> Base.hex_encode32(case: :lower)
26 |> String.to_charlist()
27 |> Enum.chunk_every(@part_size)
28 |> Enum.intersperse("-")
29 20 |> List.to_string()
30 end
31
32 def verify(recovery_codes, code_str) do
33 1 case find_valid_code(recovery_codes, code_str) do
34 1 %RecoveryCode{code: ^code_str} = code -> {:ok, code}
35 0 nil -> {:error, :invalid_code}
36 end
37 end
38
39 defp find_valid_code(recovery_codes, code_str) do
40 1 Enum.find(recovery_codes, fn rc ->
41 1 is_nil(rc.used_at) and Plug.Crypto.secure_compare(code_str, rc.code)
42 end)
43 end
44 end

lib/hexpm/accounts/session.ex

80
5
379
1
Line Hits Source
0 defmodule Hexpm.Accounts.Session do
1 use Hexpm.Schema
2
3 187 schema "sessions" do
4 field :token, :binary
5 field :data, :map
6 timestamps()
7 end
8
9 def build(data) do
10 187 change(%Session{}, data: data, token: :crypto.strong_rand_bytes(96))
11 end
12
13 def update(session, data) do
14 0 change(session, data: data)
15 end
16
17 def by_id(query \\ __MODULE__, id) do
18 4 from(s in query, where: [id: ^id])
19 end
20
21 def by_user(query \\ __MODULE__, user) do
22 1 from(s in query, where: fragment("(?->>'user_id')::integer", s.data) == ^user.id)
23 end
24 end

lib/hexpm/accounts/tfa.ex

80
5
71
1
Line Hits Source
0 defmodule Hexpm.Accounts.TFA do
1 use Hexpm.Schema
2
3 @primary_key false
4 63 embedded_schema do
5 field :secret, :string
6 field :tfa_enabled, :boolean, default: false
7 field :app_enabled, :boolean, default: false
8 embeds_many :recovery_codes, Hexpm.Accounts.RecoveryCode
9 end
10
11 def changeset(tfa, params) do
12 tfa
13 |> cast(params, ~w(secret app_enabled tfa_enabled)a)
14 0 |> cast_embed(:recovery_codes)
15 end
16
17 def generate_secret() do
18 10
19 |> :crypto.strong_rand_bytes()
20 2 |> Base.encode32()
21 end
22
23 # addwindow 1 creates a token 30 seconds ahead
24 def time_based_token(secret) do
25 2 :pot.totp(secret, addwindow: 1)
26 end
27
28 # Check a token 30 seconds ahead and within a margin of error of 1 second
29 def token_valid?(secret, token) do
30 4 :pot.valid_totp(token, secret, window: 1, addwindow: 1)
31 end
32 end

lib/hexpm/accounts/user.ex

86.4
44
9350
6
Line Hits Source
0 defmodule Hexpm.Accounts.User do
1 use Hexpm.Schema
2
3 @derive {HexpmWeb.Stale, assocs: [:emails, :owned_packages, :organizations, :keys]}
4 @derive {Phoenix.Param, key: :username}
5
6 alias Hexpm.Accounts.{RecoveryCode, TFA}
7
8 8197 schema "users" do
9 field :username, :string
10 field :full_name, :string
11 field :password, :string
12 field :service, :boolean, default: false
13 field :deactivated_at, :utc_datetime_usec
14 field :role, :string, default: "basic"
15 timestamps()
16
17 embeds_one :handles, UserHandles, on_replace: :delete
18 embeds_one :tfa, TFA, on_replace: :delete
19
20 belongs_to :organization, Organization
21 has_many :emails, Email
22 has_many :package_owners, PackageOwner
23 has_many :owned_packages, through: [:package_owners, :package]
24 has_many :organization_users, OrganizationUser
25 has_many :organizations, through: [:organization_users, :organization]
26 has_many :keys, Key
27 has_many :audit_logs, AuditLog
28 has_many :password_resets, PasswordReset
29 has_many :package_reports, Hexpm.Repository.PackageReport, foreign_key: :author_id
30 end
31
32 @username_regex ~r"^[a-z0-9_\-\.]+$"
33 @username_reject_regex ~r"(?!kneergo)$"
34 @reserved_names ~w(me hex hexpm elixir erlang otp)
35 @possible_roles ~w(basic mod)
36
37 def build(params, confirmed? \\ not Application.get_env(:hexpm, :user_confirm)) do
38 cast(%User{}, params, ~w(username full_name password)a)
39 |> validate_required(~w(username password)a)
40 9 |> cast_assoc(:emails, required: true, with: &Email.changeset(&1, :first, &2, confirmed?))
41 |> cast_embed(:tfa)
42 |> update_change(:username, &String.downcase/1)
43 |> validate_length(:username, min: 3)
44 |> validate_format(:username, @username_regex)
45 |> validate_format(:username, @username_reject_regex)
46 |> validate_exclusion(:username, @reserved_names)
47 |> unique_constraint(:username, name: "users_username_idx")
48 |> validate_length(:password, min: 7)
49 |> validate_confirmation(:password, message: "does not match password")
50 13 |> update_change(:password, &Auth.gen_password/1)
51 end
52
53 def build_organization(organization) do
54 1 username = organization_name(organization)
55
56 1 change(%User{username: username, organization_id: organization.id}, %{})
57 |> update_change(:username, &String.downcase/1)
58 |> validate_length(:username, min: 3)
59 |> validate_format(:username, @username_regex)
60 |> validate_exclusion(:username, @reserved_names)
61 1 |> unique_constraint(:username, name: "users_username_idx")
62 end
63
64 def to_organization(user, organization) do
65 0 change(user, %{password: nil, organization_id: organization.id})
66 end
67
68 def update_profile(user, params) do
69 cast(user, params, ~w(full_name)a)
70 34 |> cast_embed(:handles)
71 end
72
73 def update_password_no_check(user, params) do
74 cast(user, params, ~w(password)a)
75 |> validate_required(~w(password)a)
76 |> validate_length(:password, min: 7)
77 |> validate_confirmation(:password, message: "does not match password")
78 4 |> update_change(:password, &Auth.gen_password/1)
79 end
80
81 def update_password(user, params) do
82 6 password = user.password
83 6 user = %{user | password: nil}
84
85 cast(user, params, ~w(password)a)
86 |> validate_required(~w(password)a)
87 |> validate_length(:password, min: 7)
88 |> validate_password(:password, password)
89 |> validate_confirmation(:password, message: "does not match password")
90 6 |> update_change(:password, &Auth.gen_password/1)
91 end
92
93 def can_reset_password?(user, key) do
94 7 primary_email = email(user, :primary)
95
96 7 Enum.any?(user.password_resets, fn reset ->
97 9 PasswordReset.can_reset?(reset, primary_email, key)
98 end)
99 end
100
101 def set_role(user, params) do
102 cast(user, params, ~w(role)a)
103 |> validate_required(~w(role)a)
104 0 |> validate_inclusion(:role, @possible_roles)
105 end
106
107 126 def email(user, :primary), do: user.emails |> Enum.find(& &1.primary) |> email()
108 48 def email(user, :public), do: user.emails |> Enum.find(& &1.public) |> email()
109 100 def email(user, :gravatar), do: user.emails |> Enum.find(& &1.gravatar) |> email()
110
111 2 defp email(nil), do: nil
112 272 defp email(email), do: email.email
113
114 def get(username_or_email, preload \\ []) do
115 63 from(
116 u in Hexpm.Accounts.User,
117 where:
118 u.username == ^username_or_email or
119 ^username_or_email in fragment(
120 "SELECT emails.email FROM emails WHERE emails.user_id = ? and emails.verified",
121 u.id
122 ),
123 preload: ^preload
124 )
125 end
126
127 def public_get(username_or_email, preload \\ []) do
128 38 from(
129 u in Hexpm.Accounts.User,
130 where:
131 u.username == ^username_or_email or
132 ^username_or_email in fragment(
133 "SELECT emails.email FROM emails WHERE emails.user_id = ? and emails.verified and emails.public",
134 u.id
135 ),
136 preload: ^preload
137 )
138 end
139
140 def get_by_role(role, preload \\ []) do
141 15 from(
142 u in Hexpm.Accounts.User,
143 where: u.role == ^role,
144 preload: ^preload
145 )
146 end
147
148 8 def verify_permissions(%User{}, "api", _resource) do
149 {:ok, nil}
150 end
151
152 1 def verify_permissions(%User{}, "repositories", nil) do
153 {:ok, nil}
154 end
155
156 0 def verify_permissions(%User{}, "repository", nil) do
157 :error
158 end
159
160 def verify_permissions(%User{} = user, domain, name) when domain in ["repository", "docs"] do
161 13 organization = Organizations.get(name)
162
163 13 if organization && Organizations.access?(organization, user, "read") do
164 {:ok, organization}
165 else
166 :error
167 end
168 end
169
170 0 def verify_permissions(%User{}, _domain, _resource) do
171 :error
172 end
173
174 285 def organization?(user), do: user.organization_id != nil
175
176 # Workaround for compatibility with older Hex client tests, fixed in Hex v0.20.1
177 if Mix.env() == :hex do
178 defp organization_name(organization), do: organization.name <> "-orguser"
179 else
180 1 defp organization_name(organization), do: organization.name
181 end
182
183 0 def tfa_enabled?(%{tfa: nil}), do: false
184 5 def tfa_enabled?(%{tfa: %{tfa_enabled: true}}), do: true
185 0 def tfa_enabled?(%{tfa: %{tfa_enabled: _value}}), do: false
186
187 def update_tfa(user, changes) do
188 6 current_tfa = user.tfa || %{}
189 6 put_embed(change(user, %{}), :tfa, Map.merge(current_tfa, changes))
190 end
191
192 def recovery_code_used(user, code) do
193 1 codes = Enum.map(user.tfa.recovery_codes, &use_recovery_code(&1, code))
194 1 update_tfa(user, %{recovery_codes: codes})
195 end
196
197 def rotate_recovery_codes(user) do
198 1 codes = Hexpm.Accounts.RecoveryCode.generate_set()
199 1 update_tfa(user, %{recovery_codes: codes})
200 end
201
202 defp use_recovery_code(%RecoveryCode{code: code_str}, %RecoveryCode{code: code_str} = code) do
203 1 %{code | used_at: DateTime.utc_now()}
204 end
205
206 1 defp use_recovery_code(code, _other), do: code
207
208 def has_role?(user, role) do
209 41 user != nil and user.role == role
210 end
211 end

lib/hexpm/accounts/user_handles.ex

100
23
1117
0
Line Hits Source
0 defmodule Hexpm.Accounts.UserHandles do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4
5 703 embedded_schema do
6 field :twitter, :string
7 field :github, :string
8 field :elixirforum, :string
9 field :freenode, :string
10 field :slack, :string
11 end
12
13 def changeset(handles, params) do
14 12 cast(handles, params, ~w(twitter github elixirforum freenode slack)a)
15 end
16
17 24 def services() do
18 [
19 {:twitter, "Twitter", "https://twitter.com/{handle}"},
20 {:github, "GitHub", "https://github.com/{handle}"},
21 {:elixirforum, "Elixir Forum", "https://elixirforum.com/u/{handle}"},
22 {:freenode, "Libera", "irc://irc.libera.chat/elixir"},
23 {:slack, "Slack", "https://elixir-slackin.herokuapp.com/"}
24 ]
25 end
26
27 3 def render(%{handles: nil}) do
28 []
29 end
30
31 def render(user) do
32 24 Enum.flat_map(services(), fn {field, service, url} ->
33 120 handle = Map.get(user.handles, field)
34
35 120 if handle = handle && handle(field, handle) do
36 15 full_url = String.replace(url, "{handle}", handle)
37 [{service, handle, full_url}]
38 else
39 []
40 end
41 end)
42 end
43
44 4 def handle(:twitter, handle), do: unuri(handle, "twitter.com", "/")
45 3 def handle(:github, handle), do: unuri(handle, "github.com", "/")
46 3 def handle(:elixirforum, handle), do: unuri(handle, "elixirforum.com", "/u/")
47 6 def handle(_service, handle), do: handle
48
49 defp unuri(handle, host, path) do
50 10 uri = URI.parse(handle)
51 10 http? = uri.scheme in ["http", "https"]
52 10 host? = String.contains?(uri.host || "", host)
53 10 path? = String.starts_with?(uri.path || "", path)
54
55 10 cond do
56 10 http? and host? and path? ->
57 6 {_, handle} = String.split_at(uri.path, String.length(path))
58 6 handle
59
60 4 uri.path ->
61 3 String.replace(uri.path, host <> path, "")
62
63 1 true ->
64 nil
65 end
66 end
67 end

lib/hexpm/accounts/users.ex

89.4
160
869
17
Line Hits Source
0 defmodule Hexpm.Accounts.Users do
1 use Hexpm.Context
2
3 alias Hexpm.Accounts.{RecoveryCode, TFA}
4
5 def get(username_or_email, preload \\ []) do
6 User.get(String.downcase(username_or_email), preload)
7 60 |> Repo.one()
8 end
9
10 def public_get(username_or_email, preload \\ []) do
11 User.public_get(String.downcase(username_or_email), preload)
12 36 |> Repo.one()
13 end
14
15 def get_by_id(id, preload \\ []) do
16 Repo.get(User, id)
17 134 |> Repo.preload(preload)
18 end
19
20 def get_by_username(username, preload \\ []) do
21 Repo.get_by(User, username: String.downcase(username))
22 4 |> Repo.preload(preload)
23 end
24
25 def get_by_role(role, preload \\ []) do
26 User.get_by_role(String.downcase(role))
27 |> Repo.all()
28 15 |> Repo.preload(preload)
29 end
30
31 def get_email(email, preload \\ []) do
32 Repo.get_by(Email, email: String.downcase(email))
33 9 |> Repo.preload(preload)
34 end
35
36 16 def all_organizations(%User{organizations: organizations}) when is_list(organizations) do
37 [Organization.hexpm() | organizations]
38 end
39
40 22 def all_organizations(nil) do
41 [Organization.hexpm()]
42 end
43
44 def add(params, audit: audit_data) do
45 6 multi =
46 Multi.new()
47 |> Multi.insert(:user, User.build(params))
48 4 |> audit_with_user(audit_data, "user.create", fn %{user: user} -> user end)
49 4 |> audit_with_user(audit_data, "email.add", fn %{user: %{emails: [email]}} -> email end)
50 4 |> audit_with_user(audit_data, "email.primary", fn %{user: %{emails: [email]}} ->
51 {nil, email}
52 end)
53 4 |> audit_with_user(audit_data, "email.public", fn %{user: %{emails: [email]}} ->
54 {nil, email}
55 end)
56
57 6 case Repo.transaction(multi) do
58 {:ok, %{user: %{emails: [email]} = user}} ->
59 Emails.verification(user, email)
60 4 |> Mailer.deliver_later!()
61
62 {:ok, user}
63
64 2 {:error, :user, changeset, _} ->
65 {:error, changeset}
66 end
67 end
68
69 def email_verification(%User{organization_id: id}, email) when not is_nil(id) do
70 0 email
71 end
72
73 def email_verification(user, email) do
74 1 email =
75 Email.verification(email)
76 |> Repo.update!()
77
78 Emails.verification(user, email)
79 1 |> Mailer.deliver_later!()
80
81 1 email
82 end
83
84 def update_profile(%User{organization_id: id} = user, params, audit: audit_data)
85 when not is_nil(id) do
86 16 multi =
87 Multi.new()
88 |> Multi.update(:user, User.update_profile(user, params))
89 13 |> audit(audit_data, "user.update", fn %{user: user} -> user end)
90 |> insert_or_update_or_delete_email_multi(user, :public, params["public_email"],
91 audit: audit_data
92 )
93 |> insert_or_update_or_delete_email_multi(user, :gravatar, params["gravatar_email"],
94 audit: audit_data
95 )
96
97 16 case Repo.transaction(multi) do
98 13 {:ok, %{user: user}} ->
99 {:ok, user}
100
101 2 {:error, :public_email, _, _} ->
102 {:error,
103 %Ecto.Changeset{data: user, errors: [public_email: {"unknown error", []}], valid?: false}}
104
105 1 {:error, :gravatar_email, _, _} ->
106 {:error,
107 %Ecto.Changeset{
108 data: user,
109 errors: [gravatar_email: {"unknown error", []}],
110 valid?: false
111 }}
112 end
113 end
114
115 def update_profile(user, params, audit: audit_data) do
116 5 multi =
117 Multi.new()
118 |> Multi.update(:user, User.update_profile(user, params))
119 5 |> audit(audit_data, "user.update", fn %{user: user} -> user end)
120 |> public_email_multi(user, %{"email" => params["public_email"]}, audit: audit_data)
121 |> gravatar_email_multi(user, %{"email" => params["gravatar_email"]}, audit: audit_data)
122
123 5 case Repo.transaction(multi) do
124 5 {:ok, %{user: user}} ->
125 {:ok, user}
126
127 0 {:error, :public_email, _, _} ->
128 {:error,
129 %Ecto.Changeset{data: user, errors: [public_email: {"unknown error", []}], valid?: false}}
130
131 0 {:error, :gravatar_email, _, _} ->
132 {:error,
133 %Ecto.Changeset{
134 data: user,
135 errors: [gravatar_email: {"unknown error", []}],
136 valid?: false
137 }}
138 end
139 end
140
141 def update_password(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
142 0 organization_error(user, "cannot change password of organizations")
143 end
144
145 def update_password(user, params, audit: audit_data) do
146 4 multi =
147 Multi.new()
148 |> Multi.update(:user, User.update_password(user, params))
149 |> audit(audit_data, "password.update", nil)
150
151 4 case Repo.transaction(multi) do
152 {:ok, %{user: user}} ->
153 user
154 |> Emails.password_changed()
155 1 |> Mailer.deliver_later!()
156
157 {:ok, user}
158
159 3 {:error, :user, changeset, _} ->
160 {:error, changeset}
161 end
162 end
163
164 def tfa_enable(user, audit: audit_data) do
165 1 secret = Hexpm.Accounts.TFA.generate_secret()
166 1 codes = Hexpm.Accounts.RecoveryCode.generate_set()
167
168 1 multi =
169 Multi.new()
170 |> Multi.update(
171 :user,
172 User.update_tfa(user, %{tfa_enabled: true, secret: secret, recovery_codes: codes})
173 )
174 1 |> audit(audit_data, "security.update", fn %{user: user} -> user end)
175
176 1 {:ok, _} = Repo.transaction(multi)
177 end
178
179 def tfa_disable(user, audit: audit_data) do
180 1 multi =
181 Multi.new()
182 |> Multi.update(
183 :user,
184 User.update_tfa(user, %{tfa_enabled: false, secret: nil, recovery_codes: []})
185 )
186 1 |> audit(audit_data, "security.update", fn %{user: user} -> user end)
187
188 1 {:ok, %{user: user}} = Repo.transaction(multi)
189 1 user
190 end
191
192 def tfa_enable_app(user, verification_code, audit: audit_data) do
193 2 if TFA.token_valid?(user.tfa.secret, verification_code) do
194 1 multi =
195 Multi.new()
196 |> Multi.update(:user, User.update_tfa(user, %{app_enabled: true}))
197 1 |> audit(audit_data, "security.update", fn %{user: user} -> user end)
198
199 1 {:ok, %{user: user}} = Repo.transaction(multi)
200 {:ok, user}
201 else
202 :error
203 end
204 end
205
206 def tfa_disable_app(user, audit: audit_data) do
207 1 secret = Hexpm.Accounts.TFA.generate_secret()
208
209 1 multi =
210 Multi.new()
211 |> Multi.update(:user, User.update_tfa(user, %{app_enabled: false, secret: secret}))
212 1 |> audit(audit_data, "security.update", fn %{user: user} -> user end)
213
214 1 {:ok, %{user: user}} = Repo.transaction(multi)
215 1 user
216 end
217
218 def tfa_rotate_recovery_codes(user, audit: audit_data) do
219 1 multi =
220 Multi.new()
221 |> Multi.update(:user, User.rotate_recovery_codes(user))
222 1 |> audit(audit_data, "security.rotate_recovery_codes", fn %{user: user} -> user end)
223
224 1 {:ok, %{user: user}} = Repo.transaction(multi)
225 1 user
226 end
227
228 def verify_email(username, email, key) do
229 6 with %User{organization_id: nil, emails: emails} <- get(username, :emails),
230 5 %Email{} = email <- Enum.find(emails, &(&1.email == email)),
231 5 true <- Email.verify?(email, key),
232 3 {:ok, _} <- Email.verify(email) |> Repo.update() do
233 :ok
234 else
235 _ -> :error
236 end
237 end
238
239 def password_reset_init(name, audit: audit_data) do
240 7 user = get(name, [:emails])
241
242 7 if user && !User.organization?(user) do
243 7 changeset = PasswordReset.changeset(build_assoc(user, :password_resets), user)
244
245 7 {:ok, %{reset: reset}} =
246 Multi.new()
247 |> Multi.insert(:reset, changeset)
248 |> audit(audit_data, "password.reset.init", nil)
249 |> Repo.transaction()
250
251 Emails.password_reset_request(user, reset)
252 7 |> Mailer.deliver_later!()
253
254 :ok
255 else
256 {:error, :not_found}
257 end
258 end
259
260 def password_reset_finish(username, key, params, revoke_all_keys?, audit: audit_data) do
261 3 user = get(username, [:emails, :password_resets])
262
263 3 if user && !User.organization?(user) && User.can_reset_password?(user, key) do
264 1 multi =
265 password_reset(user, params, revoke_all_keys?)
266 |> audit(audit_data, "password.reset.finish", nil)
267
268 1 case Repo.transaction(multi) do
269 1 {:ok, _} ->
270 :ok
271
272 0 {:error, _, changeset, _} ->
273 {:error, changeset}
274 end
275 else
276 :error
277 end
278 end
279
280 defp password_reset(user, params, revoke_all_keys) do
281 1 multi =
282 Multi.new()
283 |> Multi.update(:password, User.update_password_no_check(user, params))
284 |> Multi.delete_all(:reset, assoc(user, :password_resets))
285 |> Multi.delete_all(:reset_sessions, Session.by_user(user))
286
287 1 if revoke_all_keys,
288 1 do: Multi.update_all(multi, :keys, Key.revoke_all(user), []),
289 0 else: multi
290 end
291
292 def add_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
293 0 organization_error(user, "cannot add email to organizations")
294 end
295
296 def add_email(user, params, audit: audit_data) do
297 15 email = build_assoc(user, :emails)
298
299 15 multi =
300 Multi.new()
301 |> Multi.insert(:email, Email.changeset(email, :create, params))
302 14 |> audit(audit_data, "email.add", fn %{email: email} -> email end)
303
304 15 case Repo.transaction(multi) do
305 {:ok, %{email: email}} ->
306 14 user = Repo.preload(user, :emails, force: true)
307
308 Emails.verification(user, email)
309 14 |> Mailer.deliver_later!()
310
311 {:ok, user}
312
313 1 {:error, :email, changeset, _} ->
314 {:error, changeset}
315 end
316 end
317
318 def remove_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
319 0 organization_error(user, "cannot remove email of organizations")
320 end
321
322 def remove_email(user, params, audit: audit_data) do
323 2 email = find_email(user, params)
324
325 2 cond do
326 2 !email ->
327 {:error, :unknown_email}
328
329 2 email.primary ->
330 {:error, :primary}
331
332 1 true ->
333 1 {:ok, _} =
334 Multi.new()
335 |> Ecto.Multi.delete(:email, email)
336 |> audit(audit_data, "email.remove", email)
337 |> Repo.transaction()
338
339 :ok
340 end
341 end
342
343 def primary_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
344 0 organization_error(user, "cannot set email of organizations")
345 end
346
347 def primary_email(user, params, opts) do
348 3 multi =
349 Multi.new()
350 |> email_flag_multi(user, params, :primary, opts)
351 |> Multi.delete_all(:reset, assoc(user, :password_resets))
352
353 3 case Repo.transaction(multi) do
354 2 {:ok, _} -> :ok
355 1 {:error, :primary_email, reason, _} -> {:error, reason}
356 end
357 end
358
359 def gravatar_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
360 0 organization_error(user, "cannot set email of organizations")
361 end
362
363 def gravatar_email(user, params, opts) do
364 3 multi = gravatar_email_multi(Multi.new(), user, params, opts)
365
366 3 case Repo.transaction(multi) do
367 1 {:ok, _} -> :ok
368 2 {:error, :gravatar_email, reason, _} -> {:error, reason}
369 end
370 end
371
372 defp gravatar_email_multi(multi, user, %{"email" => "none"}, opts) do
373 0 unset_email_flag_multi(multi, user, :gravatar, opts)
374 end
375
376 defp gravatar_email_multi(multi, user, params, opts) do
377 8 email_flag_multi(multi, user, params, :gravatar, opts)
378 end
379
380 def public_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do
381 0 organization_error(user, "cannot set email of organizations")
382 end
383
384 def public_email(user, params, opts) do
385 2 multi = public_email_multi(Multi.new(), user, params, opts)
386
387 2 case Repo.transaction(multi) do
388 2 {:ok, _} -> :ok
389 0 {:error, :public_email, reason, _} -> {:error, reason}
390 end
391 end
392
393 defp public_email_multi(multi, user, %{"email" => "none"}, opts) do
394 3 unset_email_flag_multi(multi, user, :public, opts)
395 end
396
397 defp public_email_multi(multi, user, params, opts) do
398 4 email_flag_multi(multi, user, params, :public, opts)
399 end
400
401 defp unset_email_flag_multi(multi, user, flag, audit: audit_data) do
402 3 if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do
403 2 old_email_op = String.to_atom("old_#{flag}")
404
405 multi
406 |> Multi.update(old_email_op, Email.toggle_flag(old_email, flag, false))
407 2 |> audit(audit_data, "email.#{flag}", {old_email, nil})
408 else
409 1 multi
410 end
411 end
412
413 defp email_flag_multi(multi, _user, %{"email" => nil}, _flag, _opts) do
414 6 multi
415 end
416
417 defp email_flag_multi(multi, user, params, flag, audit: audit_data) do
418 9 new_email = find_email(user, params)
419 9 old_email = Enum.find(user.emails, &Map.get(&1, flag))
420 9 error_op_name = String.to_atom("#{flag}_email")
421
422 9 cond do
423 9 !new_email ->
424 1 Multi.error(multi, error_op_name, :unknown_email)
425
426 8 !new_email.verified ->
427 2 Multi.error(multi, error_op_name, :not_verified)
428
429 6 old_email && new_email.id == old_email.id ->
430 0 multi
431
432 6 true ->
433 6 multi =
434 if old_email do
435 6 old_email_op_name = String.to_atom("old_#{flag}")
436 6 toggle_changeset = Email.toggle_flag(old_email, flag, false)
437 6 Multi.update(multi, old_email_op_name, toggle_changeset)
438 else
439 0 multi
440 end
441
442 6 new_email_op_name = String.to_atom("new_#{flag}")
443
444 multi
445 |> Multi.update(new_email_op_name, Email.toggle_flag(new_email, flag, true))
446 6 |> audit(audit_data, "email.#{flag}", {old_email, new_email})
447 end
448 end
449
450 def insert_or_update_or_delete_email_multi(multi, _user, _flag, nil, _params) do
451 19 multi
452 end
453
454 def insert_or_update_or_delete_email_multi(multi, user, flag, "", audit: audit_data) do
455 4 user = Repo.preload(user, :organization)
456
457 4 if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do
458 2 email_op = String.to_atom("#{flag}_email")
459
460 multi
461 |> Multi.delete(email_op, old_email)
462 2 |> audit(audit_data, "email.remove", {user.organization, old_email})
463 else
464 2 multi
465 end
466 end
467
468 def insert_or_update_or_delete_email_multi(multi, user, flag, email_address, audit: audit_data) do
469 9 email_op = String.to_atom("#{flag}_email")
470 9 user = Repo.preload(user, :organization)
471
472 9 if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do
473 multi
474 |> Multi.update(email_op, Email.update_email(old_email, email_address))
475 3 |> audit(audit_data, "email.#{flag}", fn %{^email_op => new_email} ->
476 2 {user.organization, {old_email, new_email}}
477 end)
478 else
479 multi
480 |> Multi.insert(
481 email_op,
482 Email.changeset(
483 build_assoc(user, :emails),
484 :create_for_org,
485 %{:email => email_address, flag => true},
486 false
487 )
488 )
489 4 |> audit(audit_data, "email.add", fn %{^email_op => email} -> {user.organization, email} end)
490 6 |> audit(audit_data, "email.#{flag}", fn %{^email_op => email} ->
491 4 {user.organization, {nil, email}}
492 end)
493 end
494 end
495
496 def resend_verify_email(user, params) do
497 1 email = find_email(user, params)
498
499 1 cond do
500 1 !email ->
501 {:error, :unknown_email}
502
503 1 email.verified ->
504 {:error, :already_verified}
505
506 1 true ->
507 Emails.verification(user, email)
508 1 |> Mailer.deliver_later!()
509
510 :ok
511 end
512 end
513
514 def tfa_recover(%User{} = user, code_str) do
515 1 case RecoveryCode.verify(user.tfa.recovery_codes, code_str) do
516 {:ok, %RecoveryCode{} = code} ->
517 1 user =
518 user
519 |> User.recovery_code_used(code)
520 |> Repo.update!()
521
522 {:ok, user}
523
524 err ->
525 0 err
526 end
527 end
528
529 defp find_email(user, params) do
530 12 Enum.find(user.emails, &(&1.email == params["email"]))
531 end
532
533 0 defp organization_error(user, message) do
534 {:error,
535 %Ecto.Changeset{
536 data: user,
537 errors: [organization: {message, []}],
538 valid?: false
539 }}
540 end
541 end

lib/hexpm/application.ex

87.5
16
14
2
Line Hits Source
0 defmodule Hexpm.Application do
1 use Application
2
3 def start(_type, _args) do
4 1 topologies = cluster_topologies()
5 1 read_only_mode()
6 1 Hexpm.BlockAddress.start()
7
8 1 children = [
9 Hexpm.RepoBase,
10 {Task.Supervisor, name: Hexpm.Tasks},
11 {Cluster.Supervisor, [topologies, [name: Hexpm.ClusterSupervisor]]},
12 {Phoenix.PubSub, name: Hexpm.PubSub, adapter: Phoenix.PubSub.PG2},
13 HexpmWeb.RateLimitPubSub,
14 {PlugAttack.Storage.Ets, name: HexpmWeb.Plugs.Attack.Storage, clean_period: 60_000},
15 {Hexpm.Billing.Report, name: Hexpm.Billing.Report, interval: 60_000},
16 goth_spec(),
17 HexpmWeb.Telemetry,
18 HexpmWeb.Endpoint
19 ]
20
21 1 File.mkdir_p(Application.get_env(:hexpm, :tmp_dir))
22 1 shutdown_on_eof()
23
24 1 opts = [strategy: :one_for_one, name: Hexpm.Supervisor]
25 1 Supervisor.start_link(children, opts)
26 end
27
28 def config_change(changed, _new, removed) do
29 0 HexpmWeb.Endpoint.config_change(changed, removed)
30 :ok
31 end
32
33 # Make sure we exit after hex client tests are finished running
34 if Mix.env() == :hex do
35 def shutdown_on_eof() do
36 spawn_link(fn ->
37 IO.gets(:stdio, '') == :eof && System.halt(0)
38 end)
39 end
40 else
41 1 def shutdown_on_eof(), do: nil
42 end
43
44 defp read_only_mode() do
45 1 mode = System.get_env("HEXPM_READ_ONLY_MODE") == "1"
46 1 Application.put_env(:hexpm, :read_only_mode, mode)
47 end
48
49 defp cluster_topologies() do
50 1 if System.get_env("HEXPM_CLUSTER") == "1" do
51 0 Application.get_env(:hexpm, :topologies) || []
52 else
53 []
54 end
55 end
56
57 if Mix.env() == :prod do
58 defp goth_spec() do
59 credentials =
60 "HEXPM_GCP_CREDENTIALS"
61 |> System.fetch_env!()
62 |> Jason.decode!()
63
64 options = [scope: "https://www.googleapis.com/auth/devstorage.read_write"]
65 {Goth, name: Hexpm.Goth, source: {:service_account, credentials, options}}
66 end
67 else
68 1 defp goth_spec() do
69 1 {Task, fn -> :ok end}
70 end
71 end
72 end

lib/hexpm/billing/billing.ex

81.5
27
171
5
Line Hits Source
0 defmodule Hexpm.Billing do
1 use Hexpm.Context
2
3 @type organization() :: String.t()
4
5 @callback checkout(organization(), data :: map()) :: {:ok, map()} | {:error, map()}
6 @callback get(organization()) :: map() | nil
7 @callback cancel(organization()) :: map()
8 @callback create(map()) :: {:ok, map()} | {:error, map()}
9 @callback update(organization(), map()) :: {:ok, map()} | {:error, map()}
10 @callback change_plan(organization(), map()) :: :ok
11 @callback invoice(id :: pos_integer()) :: binary()
12 @callback pay_invoice(id :: pos_integer()) :: :ok | {:error, map()}
13 @callback report() :: [map()]
14
15 64 defp impl(), do: Application.get_env(:hexpm, :billing_impl)
16
17 0 def checkout(organization, data), do: impl().checkout(organization, data)
18 22 def get(organization), do: impl().get(organization)
19 0 def cancel(organization), do: impl().cancel(organization)
20 0 def create(params), do: impl().create(params)
21 1 def update(organization, params), do: impl().update(organization, params)
22 0 def change_plan(organization, params), do: impl().change_plan(organization, params)
23 1 def invoice(id), do: impl().invoice(id)
24 0 def pay_invoice(id), do: impl().pay_invoice(id)
25 2 def report(), do: impl().report()
26
27 @doc """
28 Change payment method used by an organization.
29 """
30 def checkout(organization_name, data,
31 audit: %{audit_data: audit_data, organization: organization}
32 ) do
33 6 case impl().checkout(organization_name, data) do
34 {:ok, body} ->
35 4 Repo.insert!(audit(audit_data, "billing.checkout", {organization, data}))
36 {:ok, body}
37
38 2 {:error, reason} ->
39 {:error, reason}
40 end
41 end
42
43 def cancel(params, audit: %{audit_data: audit_data, organization: organization}) do
44 5 result = impl().cancel(params)
45 5 Repo.insert!(audit(audit_data, "billing.cancel", {organization, params}))
46 5 result
47 end
48
49 def create(params, audit: %{audit_data: audit_data, organization: organization}) do
50 6 case impl().create(params) do
51 {:ok, result} ->
52 4 Repo.insert!(audit(audit_data, "billing.create", {organization, params}))
53 {:ok, result}
54
55 2 {:error, reason} ->
56 {:error, reason}
57 end
58 end
59
60 def update(organization_name, params,
61 audit: %{audit_data: audit_data, organization: organization}
62 ) do
63 10 case impl().update(organization_name, params) do
64 {:ok, result} ->
65 8 Repo.insert!(audit(audit_data, "billing.update", {organization, params}))
66 {:ok, result}
67
68 2 {:error, reason} ->
69 {:error, reason}
70 end
71 end
72
73 def change_plan(organization_name, params,
74 audit: %{audit_data: audit_data, organization: organization}
75 ) do
76 4 impl().change_plan(organization_name, params)
77 4 Repo.insert!(audit(audit_data, "billing.change_plan", {organization, params}))
78 :ok
79 end
80
81 def pay_invoice(id, audit: %{audit_data: audit_data, organization: organization}) do
82 7 case impl().pay_invoice(id) do
83 :ok ->
84 4 Repo.insert!(audit(audit_data, "billing.pay_invoice", {organization, id}))
85 :ok
86
87 3 {:error, reason} ->
88 {:error, reason}
89 end
90 end
91 end

lib/hexpm/billing/hexpm.ex

0
54
0
54
Line Hits Source
0 defmodule Hexpm.Billing.Hexpm do
1 @behaviour Hexpm.Billing
2
3 @timeout 15_000
4
5 def checkout(organization, data) do
6 0 case post("/api/customers/#{organization}/payment_source", data) do
7 0 {:ok, 204, _headers, body} -> {:ok, body}
8 0 {:ok, 422, _headers, body} -> {:error, body}
9 end
10 end
11
12 def get(organization) do
13 0 result =
14 0 fn -> get_json("/api/customers/#{organization}") end
15 |> Hexpm.HTTP.retry("billing")
16
17 0 case result do
18 0 {:ok, 200, _headers, body} -> body
19 0 {:ok, 404, _headers, _body} -> nil
20 end
21 end
22
23 def cancel(organization) do
24 0 {:ok, 200, _headers, body} = post("/api/customers/#{organization}/cancel", %{})
25 0 body
26 end
27
28 def create(params) do
29 0 case post("/api/customers", params) do
30 0 {:ok, 200, _headers, body} -> {:ok, body}
31 0 {:ok, 422, _headers, body} -> {:error, body}
32 end
33 end
34
35 def update(organization, params) do
36 0 case patch("/api/customers/#{organization}", params) do
37 0 {:ok, 200, _headers, body} -> {:ok, body}
38 0 {:ok, 404, _headers, _body} -> {:ok, nil}
39 0 {:ok, 422, _headers, body} -> {:error, body}
40 end
41 end
42
43 def change_plan(organization, params) do
44 0 {:ok, 204, _headers, _body} = post("/api/customers/#{organization}/plan", params)
45 :ok
46 end
47
48 def invoice(id) do
49 0 {:ok, 200, _headers, body} =
50 0 fn -> get_html("/api/invoices/#{id}/html") end
51 |> Hexpm.HTTP.retry("billing")
52
53 0 body
54 end
55
56 def pay_invoice(id) do
57 0 result =
58 0 fn -> post("/api/invoices/#{id}/pay", %{}) end
59 |> Hexpm.HTTP.retry("billing")
60
61 0 case result do
62 0 {:ok, 204, _headers, _body} -> :ok
63 0 {:ok, 422, _headers, body} -> {:error, body}
64 end
65 end
66
67 def report() do
68 0 {:ok, 200, _headers, body} =
69 0 fn -> get_json("/api/reports/customers") end
70 |> Hexpm.HTTP.retry("billing")
71
72 0 body
73 end
74
75 defp auth() do
76 0 Application.get_env(:hexpm, :billing_key)
77 end
78
79 defp post(url, body) do
80 0 url = Application.get_env(:hexpm, :billing_url) <> url
81 0 body = Jason.encode!(body)
82
83 0 headers = [
84 {"authorization", auth()},
85 {"accept", "application/json"},
86 {"content-type", "application/json"}
87 ]
88
89 :hackney.post(url, headers, body, recv_timeout: @timeout)
90 0 |> read_request()
91 end
92
93 defp patch(url, body) do
94 0 url = Application.get_env(:hexpm, :billing_url) <> url
95 0 body = Jason.encode!(body)
96
97 0 headers = [
98 {"authorization", auth()},
99 {"accept", "application/json"},
100 {"content-type", "application/json"}
101 ]
102
103 :hackney.patch(url, headers, body, recv_timeout: @timeout)
104 0 |> read_request()
105 end
106
107 defp get_json(url) do
108 0 url = Application.get_env(:hexpm, :billing_url) <> url
109
110 0 headers = [
111 {"authorization", auth()},
112 {"accept", "application/json"}
113 ]
114
115 :hackney.get(url, headers, "", recv_timeout: @timeout)
116 0 |> read_request()
117 end
118
119 defp get_html(url) do
120 0 url = Application.get_env(:hexpm, :billing_url) <> url
121
122 0 headers = [
123 {"authorization", auth()},
124 {"accept", "text/html"}
125 ]
126
127 :hackney.get(url, headers, "", recv_timeout: @timeout)
128 0 |> read_request()
129 end
130
131 defp read_request(result) do
132 0 with {:ok, status, headers, ref} <- result,
133 0 headers = normalize_headers(headers),
134 0 {:ok, body} <- :hackney.body(ref),
135 0 {:ok, body} <- decode_body(body, headers) do
136 0 {:ok, status, headers, body}
137 end
138 end
139
140 defp decode_body(body, headers) do
141 0 case List.keyfind(headers, "content-type", 0) do
142 0 nil ->
143 {:ok, nil}
144
145 {_, content_type} ->
146 0 if String.contains?(content_type, "application/json") do
147 0 Jason.decode(body)
148 else
149 {:ok, body}
150 end
151 end
152 end
153
154 defp normalize_headers(headers) do
155 0 Enum.map(headers, fn {key, value} ->
156 {String.downcase(key), value}
157 end)
158 end
159 end

lib/hexpm/billing/local.ex

0
9
0
9
Line Hits Source
0 defmodule Hexpm.Billing.Local do
1 @behaviour Hexpm.Billing
2
3 0 def checkout(_organization, _data) do
4 {:ok, %{}}
5 end
6
7 def get(_organization) do
8 0 %{
9 "checkout_html" => "",
10 "monthly_cost" => 800,
11 "invoices" => []
12 }
13 end
14
15 def cancel(_organization) do
16 0 %{}
17 end
18
19 0 def create(_params) do
20 {:ok, %{}}
21 end
22
23 0 def update(_organization, _params) do
24 {:ok, %{}}
25 end
26
27 0 def change_plan(_organization, _params) do
28 :ok
29 end
30
31 def invoice(_id) do
32 0 %{}
33 end
34
35 0 def pay_invoice(_id) do
36 :ok
37 end
38
39 0 def report() do
40 []
41 end
42 end

lib/hexpm/billing/report.ex

100
20
57
0
Line Hits Source
0 defmodule Hexpm.Billing.Report do
1 use GenServer
2 import Ecto.Query, only: [from: 2]
3 alias Hexpm.Repo
4 alias Hexpm.Accounts.Organization
5
6 @report_timeout 20_000
7
8 def start_link(opts \\ []) do
9 3 GenServer.start_link(__MODULE__, opts, opts)
10 end
11
12 def init(opts) do
13 3 Process.send_after(self(), :update, opts[:interval])
14 {:ok, opts}
15 end
16
17 def handle_info(:update, opts) do
18 2 if Application.fetch_env!(:hexpm, :billing_report) and Repo.write_mode?() do
19 2 report = report()
20 2 organizations = organizations()
21
22 2 set_active(organizations, report)
23 2 set_inactive(organizations, report)
24 end
25
26 2 Process.send_after(self(), :update, opts[:interval])
27 {:noreply, opts}
28 end
29
30 defp report() do
31 report_request()
32 |> MapSet.new()
33 2 |> MapSet.put("hexpm")
34 end
35
36 defp report_request() do
37 2 Task.async(fn -> Hexpm.Billing.report() end)
38 2 |> Task.await(@report_timeout)
39 end
40
41 defp organizations() do
42 from(r in Organization, select: {r.name, r.billing_active})
43 2 |> Repo.all()
44 end
45
46 defp set_active(organizations, report) do
47 2 to_update =
48 Enum.flat_map(organizations, fn {name, active} ->
49 10 if not active and name in report do
50 [name]
51 else
52 []
53 end
54 end)
55
56 2 if to_update != [] do
57 from(r in Organization, where: r.name in ^to_update)
58 1 |> Repo.update_all(set: [billing_active: true])
59 end
60 end
61
62 defp set_inactive(organizations, report) do
63 2 to_update =
64 Enum.flat_map(organizations, fn {name, active} ->
65 10 if active and name not in report do
66 [name]
67 else
68 []
69 end
70 end)
71
72 2 if to_update != [] do
73 from(r in Organization, where: r.name in ^to_update)
74 2 |> Repo.update_all(set: [billing_active: false])
75 end
76 end
77 end

lib/hexpm/block_address/block_address.ex

88.2
17
7460
2
Line Hits Source
0 defmodule Hexpm.BlockAddress do
1 @ets :blocked_addresses
2
3 def start() do
4 1 :ets.new(@ets, [:named_table, :set, :public, read_concurrency: true])
5 1 :ets.insert(@ets, {:loaded, false})
6 end
7
8 def try_reload() do
9 1237 case :ets.lookup(@ets, :loaded) do
10 [{:loaded, false}] ->
11 0 reload()
12
13 1237 _ ->
14 :ok
15 end
16 end
17
18 def reload() do
19 7 disallowed =
20 Hexpm.BlockAddress.Entry
21 |> Hexpm.Repo.all()
22 4 |> Enum.map(&Hexpm.Utils.parse_ip_mask(&1.ip))
23 4 |> Enum.reject(fn {ip, _mask} -> ip == nil end)
24 |> Enum.uniq()
25
26 7 :ets.insert(@ets, {:allowed, Hexpm.CDN.public_ips()})
27 7 :ets.insert(@ets, {:disallowed, disallowed})
28 7 :ets.insert(@ets, {:loaded, true})
29 end
30
31 def blocked?(ip) do
32 618 lookup_ip_mask(:disallowed, ip)
33 end
34
35 def allowed?(ip) do
36 619 lookup_ip_mask(:allowed, ip)
37 end
38
39 defp lookup_ip_mask(key, ip) do
40 1237 case :ets.lookup(@ets, key) do
41 [{^key, masks}] ->
42 1237 ip = Hexpm.Utils.parse_ip(ip)
43 1237 Hexpm.Utils.in_ip_range?(masks, ip)
44
45 0 [] ->
46 false
47 end
48 end
49 end

lib/hexpm/block_address/entry.ex

100
1
12
0
Line Hits Source
0 defmodule Hexpm.BlockAddress.Entry do
1 use Hexpm.Schema
2
3 # TODO: rename to block_address_entries
4 12 schema "blocked_addresses" do
5 field :ip, :string
6 field :comment, :string
7 end
8 end

lib/hexpm/cdn/cdn.ex

100
3
302
0
Line Hits Source
0 defmodule Hexpm.CDN do
1 @type service :: atom
2 @type key :: String.t()
3 @type ip :: <<_::32>>
4 @type mask :: 0..32
5
6 @callback purge_key(service, key | [key]) :: :ok
7 @callback public_ips() :: [{ip, mask}]
8
9 151 defp impl(), do: Application.get_env(:hexpm, :cdn_impl)
10
11 144 def purge_key(service, key), do: impl().purge_key(service, key)
12 7 def public_ips(), do: impl().public_ips()
13 end

lib/hexpm/cdn/fastly.ex

0
21
0
21
Line Hits Source
0 defmodule Hexpm.CDN.Fastly do
1 @behaviour Hexpm.CDN
2 @fastly_url "https://api.fastly.com/"
3
4 def purge_key(service, keys) do
5 0 keys = keys |> List.wrap() |> Enum.uniq()
6 0 body = %{"surrogate_keys" => keys}
7 0 service_id = Application.get_env(:hexpm, service)
8
9 0 {:ok, 200, _, _} = post("service/#{service_id}/purge", body)
10 :ok
11 end
12
13 def public_ips() do
14 0 {:ok, 200, _, body} = get("public-ip-list")
15 0 Enum.map(body["addresses"], &Hexpm.Utils.parse_ip_mask/1)
16 end
17
18 defp auth() do
19 0 Application.get_env(:hexpm, :fastly_key)
20 end
21
22 defp post(url, body) do
23 0 url = @fastly_url <> url
24
25 0 headers = [
26 "fastly-key": auth(),
27 accept: "application/json",
28 "content-type": "application/json"
29 ]
30
31 0 body = Jason.encode!(body)
32
33 0 fn -> :hackney.post(url, headers, body, []) end
34 |> Hexpm.HTTP.retry("fastly")
35 0 |> read_body()
36 end
37
38 defp get(url) do
39 0 url = @fastly_url <> url
40 0 headers = ["fastly-key": auth(), accept: "application/json"]
41
42 0 fn -> :hackney.get(url, headers, []) end
43 |> Hexpm.HTTP.retry("fastly")
44 0 |> read_body()
45 end
46
47 defp read_body({:ok, status, headers, client}) do
48 0 {:ok, body} = :hackney.body(client)
49
50 0 body =
51 case Jason.decode(body) do
52 0 {:ok, map} -> map
53 0 {:error, _} -> body
54 end
55
56 0 {:ok, status, headers, body}
57 end
58 end

lib/hexpm/cdn/local.ex

100
2
151
0
Line Hits Source
0 defmodule Hexpm.CDN.Local do
1 @behaviour Hexpm.CDN
2
3 144 def purge_key(_service, _key), do: :ok
4 7 def public_ips, do: [{<<127, 0, 0, 0>>, 24}]
5 end

lib/hexpm/context.ex

0
0
0
0
Line Hits Source
0 defmodule Hexpm.Context do
1 defmacro __using__(_opts) do
2 quote do
3 import Ecto
4 import Ecto.Changeset
5 import Ecto.Query, only: [from: 1, from: 2]
6
7 import Hexpm.Accounts.AuditLog,
8 only: [audit: 3, audit: 4, audit_many: 4, audit_with_user: 4]
9
10 alias Ecto.Multi
11 alias Hexpm.Repo
12
13 use Hexpm.Shared
14 end
15 end
16 end

lib/hexpm/ecto/changeset.ex

86.8
38
1371
5
Line Hits Source
0 defmodule Hexpm.Changeset do
1 @moduledoc """
2 Ecto changeset helpers.
3 """
4
5 import Ecto.Changeset
6
7 @doc """
8 Checks if a version is valid semver.
9 """
10 def validate_version(changeset, field) do
11 90 validate_change(changeset, field, fn
12 70 _, %Version{build: nil} ->
13 []
14
15 0 _, %Version{} ->
16 [{field, "build number not allowed"}]
17 end)
18 end
19
20 def validate_list_required(changeset, field, opts \\ []) do
21 174 validate_change(changeset, field, fn
22 2 _, [] ->
23 [{field, Keyword.get(opts, :message, "can't be blank")}]
24
25 167 _, list when is_list(list) ->
26 []
27 end)
28 end
29
30 def validate_requirement(changeset, field) do
31 110 validate_change(changeset, field, fn key, req ->
32 24 cond do
33 0 is_nil(req) ->
34 [{key, "invalid requirement: #{inspect(req)}, use \">= 0.0.0\" instead"}]
35
36 24 not valid_requirement?(req) ->
37 [{key, "invalid requirement: #{inspect(req)}"}]
38
39 21 String.contains?(req, "!=") ->
40 [{key, "invalid requirement: #{inspect(req)}, != is not allowed in requirements"}]
41
42 21 true ->
43 []
44 end
45 end)
46 end
47
48 defp valid_requirement?(req) do
49 24 is_binary(req) and match?({:ok, _}, Version.parse_requirement(req))
50 end
51
52 def validate_verified_email_exists(changeset, field, opts) do
53 25 validate_change(changeset, field, fn _, email ->
54 23 case Hexpm.Repo.get_by(Hexpm.Accounts.Email, email: email, verified: true) do
55 21 nil ->
56 []
57
58 2 _ ->
59 [{field, opts[:message]}]
60 end
61 end)
62 end
63
64 def validate_repository(changeset, field, opts) do
65 23 validate_change(changeset, field, fn key, dependency_repository ->
66 16 organization = Keyword.fetch!(opts, :repository)
67
68 16 if dependency_repository in ["hexpm", organization.name] do
69 []
70 else
71 [{key, {repository_error(organization, dependency_repository), []}}]
72 end
73 end)
74 end
75
76 defp repository_error(%{id: 1}, dependency_repository) do
77 2 "dependencies can only belong to public repository \"hexpm\", " <>
78 "got: #{inspect(dependency_repository)}"
79 end
80
81 defp repository_error(%{name: name}, dependency_repository) do
82 0 "dependencies can only belong to public repository \"hexpm\" " <>
83 "or current repository #{inspect(name)}, got: #{inspect(dependency_repository)}"
84 end
85
86 def validate_password(changeset, field, hash, opts \\ []) do
87 6 error_param = "#{field}_current"
88 6 error_field = String.to_atom(error_param)
89
90 6 errors =
91 6 case Map.fetch(changeset.params, error_param) do
92 {:ok, value} ->
93 3 hash = default_hash(hash)
94
95 3 if Bcrypt.verify_pass(value, hash),
96 do: [],
97 else: [{error_field, {"is invalid", []}}]
98
99 3 :error ->
100 [{error_field, {"can't be blank", []}}]
101 end
102
103 %{
104 changeset
105 6 | validations: [{:password, opts} | changeset.validations],
106 6 errors: errors ++ changeset.errors,
107 6 valid?: changeset.valid? and errors == []
108 }
109 end
110
111 @default_password Bcrypt.hash_pwd_salt("password")
112
113 0 defp default_hash(nil), do: @default_password
114 0 defp default_hash(""), do: @default_password
115 3 defp default_hash(password), do: password
116
117 def put_default_embed(changeset, key, value) do
118 231 if get_change(changeset, key) do
119 4 changeset
120 else
121 227 put_embed(changeset, key, value)
122 end
123 end
124 end

lib/hexpm/ecto/version.ex

66.7
15
3873
5
Line Hits Source
0 defmodule Hexpm.Version do
1 @behaviour Ecto.Type
2
3 1104 def type(), do: :string
4
5 234 def cast(%Version{} = version), do: {:ok, version}
6
7 def cast(string) when is_binary(string) do
8 594 case Version.parse(string) do
9 {:ok, _} = ok ->
10 590 ok
11
12 4 :error ->
13 {:error, message: "is invalid SemVer"}
14 end
15 end
16
17 0 def cast(_), do: {:error, message: "is invalid SemVer"}
18
19 407 def load(string), do: Version.parse(string)
20
21 697 def dump(%Version{} = version), do: {:ok, to_string(version)}
22 0 def dump(version) when is_binary(version), do: {:ok, version}
23
24 0 def embed_as(_format), do: :self
25
26 0 def equal?(nil, nil), do: true
27 70 def equal?(nil, _right), do: false
28 0 def equal?(_left, nil), do: false
29 12 def equal?(left, right), do: Version.compare(left, right) == :eq
30 end
31
32 defimpl Jason.Encoder, for: Version do
33 161 def encode(version, _), do: ~s("#{version}")
34 end

lib/hexpm/emails/bamboo.ex

100
4
258
0
Line Hits Source
0 defimpl Bamboo.Formatter, for: Hexpm.Accounts.User do
1 104 def format_email_address(user, _opts) do
2 104 {user.username, Hexpm.Accounts.User.email(user, :primary)}
3 end
4 end
5
6 defimpl Bamboo.Formatter, for: Hexpm.Accounts.Email do
7 25 def format_email_address(email, _opts) do
8 25 {email.user.username, email.email}
9 end
10 end

lib/hexpm/emails/emails.ex

95.2
42
1108
2
Line Hits Source
0 defmodule Hexpm.Emails do
1 use Bamboo.Phoenix, view: HexpmWeb.EmailView
2 import Bamboo.Email
3 alias Hexpm.Accounts.{Email, User}
4
5 def owner_added(package, owners, owner) do
6 email()
7 |> email_to(owners)
8 11 |> subject("Hex.pm - Owner added to package #{package.name}")
9 11 |> assign(:username, owner.username)
10 11 |> assign(:package, package.name)
11 11 |> render(:owner_add)
12 end
13
14 def owner_removed(package, owners, owner) do
15 email()
16 |> email_to(owners)
17 4 |> subject("Hex.pm - Owner removed from package #{package.name}")
18 4 |> assign(:username, owner.username)
19 4 |> assign(:package, package.name)
20 4 |> render(:owner_remove)
21 end
22
23 def verification(user, email) do
24 email()
25 |> email_to(%{email | user: user})
26 |> subject("Hex.pm - Email verification")
27 25 |> assign(:username, user.username)
28 25 |> assign(:email, email.email)
29 25 |> assign(:key, email.verification_key)
30 25 |> render(:verification)
31 end
32
33 def password_reset_request(user, reset) do
34 email()
35 |> email_to(user)
36 |> subject("Hex.pm - Password reset request")
37 11 |> assign(:username, user.username)
38 11 |> assign(:key, reset.key)
39 11 |> render(:password_reset_request)
40 end
41
42 def password_changed(user) do
43 email()
44 |> email_to(user)
45 |> subject("Hex.pm - Your password has changed")
46 2 |> assign(:username, user.username)
47 2 |> render(:password_changed)
48 end
49
50 def typosquat_candidates(candidates, threshold) do
51 email()
52 |> email_to(Application.get_env(:hexpm, :support_email))
53 |> subject("[TYPOSQUAT CANDIDATES]")
54 |> assign(:candidates, candidates)
55 |> assign(:threshold, threshold)
56 0 |> render(:typosquat_candidates)
57 end
58
59 def organization_invite(organization, user) do
60 email()
61 |> email_to(user)
62 3 |> subject("Hex.pm - You have been added to the #{organization.name} organization")
63 3 |> assign(:organization, organization.name)
64 3 |> assign(:username, user.username)
65 3 |> render(:organization_invite)
66 end
67
68 def package_published(owners, publisher, name, version) do
69 email()
70 |> email_to(owners)
71 28 |> subject("Hex.pm - Package #{name} v#{version} published")
72 |> assign(:publisher, publisher)
73 |> assign(:version, version)
74 |> assign(:package, name)
75 28 |> render(:package_published)
76 end
77
78 def report_submitted(receiver, author_name, package_name, report_id, inserted_at) do
79 email()
80 |> email_to(receiver)
81 7 |> subject("Hex.pm - Package report on #{package_name} published ")
82 |> assign(:package_name, package_name)
83 |> assign(:author_name, author_name)
84 |> assign(:report_id, report_id)
85 |> assign(:inserted_at, inserted_at)
86 7 |> render(:report_submitted)
87 end
88
89 def report_commented(receiver, author_name, report_id, inserted_at) do
90 email()
91 |> email_to(receiver)
92 4 |> subject("Hex.pm - New comment on package report ##{report_id}")
93 |> assign(:author_name, author_name)
94 |> assign(:report_id, report_id)
95 |> assign(:inserted_at, inserted_at)
96 4 |> render(:report_commented)
97 end
98
99 def report_state_changed(receiver, report_id, new_state, updated_at) do
100 email()
101 |> email_to(receiver)
102 29 |> subject("Hex.pm - Package report ##{report_id} has been reviewed by a moderator")
103 |> assign(:report_id, report_id)
104 |> assign(:new_state, new_state)
105 |> assign(:updated_at, updated_at)
106 29 |> render(:report_state_changed)
107 end
108
109 defp email_to(email, to) do
110 124 to =
111 to
112 |> List.wrap()
113 |> Enum.flat_map(&expand_organization/1)
114 |> Enum.sort()
115
116 124 to(email, to)
117 end
118
119 0 defp expand_organization(email) when is_binary(email), do: [email]
120 25 defp expand_organization(%Email{} = email), do: [email]
121 44 defp expand_organization(%User{organization: nil} = user), do: [user]
122 60 defp expand_organization(%User{organization: %Ecto.Association.NotLoaded{}} = user), do: [user]
123
124 defp expand_organization(%User{organization: organization}) do
125 5 organization.organization_users
126 4 |> Enum.filter(&(&1.role == "admin"))
127 5 |> Enum.map(&User.email(&1.user, :primary))
128 end
129
130 defp email() do
131 new_email()
132 |> from(source())
133 124 |> put_layout({HexpmWeb.EmailView, :layout})
134 end
135
136 defp source() do
137 124 host = Application.get_env(:hexpm, :email_host) || "hex.pm"
138 124 {"Hex.pm", "noreply@#{host}"}
139 end
140 end

lib/hexpm/emails/mailer.ex

0
0
0
0
Line Hits Source
0 defmodule Hexpm.Emails.Mailer do
1 use Bamboo.Mailer, otp_app: :hexpm
2 end

lib/hexpm/factory.ex

100
27
9412
0
Line Hits Source
0 defmodule Hexpm.Factory do
1 use ExMachina.Ecto, repo: Hexpm.Repo
2 alias Hexpm.Fake
3
4 @password Bcrypt.hash_pwd_salt("password")
5 @checksum "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
6
7 def user_factory() do
8 1619 %Hexpm.Accounts.User{
9 username: Fake.sequence(:username),
10 password: @password,
11 full_name: Fake.random(:full_name),
12 emails: [build(:email)]
13 }
14 end
15
16 def email_factory() do
17 1673 %Hexpm.Accounts.Email{
18 email: Fake.sequence(:email),
19 verified: true,
20 primary: true,
21 public: true,
22 gravatar: true
23 }
24 end
25
26 def key_factory() do
27 164 {user_secret, first, second} = Hexpm.Accounts.Key.gen_key()
28
29 164 %Hexpm.Accounts.Key{
30 164 name: "#{Fake.random(:username)}-#{:erlang.unique_integer()}",
31 secret_first: first,
32 secret_second: second,
33 user_secret: user_secret,
34 permissions: [build(:key_permission, domain: "api")],
35 user: nil,
36 organization: nil
37 }
38 end
39
40 def key_permission_factory() do
41 350 %Hexpm.Accounts.KeyPermission{}
42 end
43
44 def user_handles_factory() do
45 5 %Hexpm.Accounts.UserHandles{}
46 end
47
48 def organization_factory() do
49 491 name = Fake.sequence(:package)
50
51 491 %Hexpm.Accounts.Organization{
52 name: name,
53 user: build(:user, username: name),
54 billing_active: true,
55 trial_end: ~U[2020-01-01T00:00:00Z]
56 }
57 end
58
59 def audit_log_factory() do
60 201 %Hexpm.Accounts.AuditLog{
61 action: "",
62 params: %{}
63 }
64 end
65
66 def repository_factory() do
67 342 name = Fake.sequence(:package)
68
69 342 %Hexpm.Repository.Repository{
70 name: name,
71 organization: build(:organization, name: name, user: build(:user, username: name))
72 }
73 end
74
75 def package_factory() do
76 624 %Hexpm.Repository.Package{
77 name: Fake.sequence(:package),
78 meta: build(:package_metadata),
79 repository_id: 1
80 }
81 end
82
83 def package_metadata_factory() do
84 624 %Hexpm.Repository.PackageMetadata{
85 description: Fake.random(:sentence),
86 licenses: ["MIT"]
87 }
88 end
89
90 def package_owner_factory() do
91 260 %Hexpm.Repository.PackageOwner{}
92 end
93
94 def package_report_factory() do
95 115 %Hexpm.Repository.PackageReport{}
96 end
97
98 def package_report_release_factory() do
99 46 %Hexpm.Repository.PackageReportRelease{}
100 end
101
102 def organization_user_factory() do
103 249 %Hexpm.Accounts.OrganizationUser{
104 role: "read"
105 }
106 end
107
108 def release_factory() do
109 541 %Hexpm.Repository.Release{
110 version: "1.0.0",
111 inner_checksum: Base.decode16!(@checksum),
112 outer_checksum: Base.decode16!(@checksum),
113 meta: build(:release_metadata)
114 }
115 end
116
117 def release_metadata_factory() do
118 823 %Hexpm.Repository.ReleaseMetadata{
119 app: Fake.random(:package),
120 build_tools: ["mix"]
121 }
122 end
123
124 def requirement_factory() do
125 32 %Hexpm.Repository.Requirement{
126 app: Fake.random(:package),
127 optional: false
128 }
129 end
130
131 def download_factory() do
132 32 %Hexpm.Repository.Download{
133 day: ~D[2017-01-01]
134 }
135 end
136
137 def install_factory() do
138 16 %Hexpm.Repository.Install{}
139 end
140
141 def block_address_factory() do
142 4 %Hexpm.BlockAddress.Entry{
143 comment: "blocked"
144 }
145 end
146
147 def short_url_factory() do
148 1 %Hexpm.ShortURLs.ShortURL{
149 url: "",
150 short_code: ""
151 }
152 end
153
154 def user_with_tfa_factory() do
155 19 %Hexpm.Accounts.User{
156 username: Fake.sequence(:username),
157 password: @password,
158 full_name: Fake.random(:full_name),
159 emails: [build(:email)],
160 tfa: build(:tfa)
161 }
162 end
163
164 def tfa_factory() do
165 20 %Hexpm.Accounts.TFA{
166 secret: "OZIH4PZP53MCYZ6Z",
167 app_enabled: true,
168 tfa_enabled: true,
169 recovery_codes: [
170 %{
171 id: Ecto.UUID.generate(),
172 code: "1234-1234-1234-1234",
173 used_at: nil
174 },
175 %{
176 id: Ecto.UUID.generate(),
177 code: "4321-4321-4321-4321",
178 used_at: ~U[2020-01-01 00:00:00Z]
179 }
180 ]
181 }
182 end
183 end

lib/hexpm/fake.ex

90.2
41
78978
4
Line Hits Source
0 defmodule Hexpm.Fake do
1 @files [
2 :packages,
3 :first_names,
4 :last_names,
5 :usernames,
6 :words
7 ]
8
9 @generators [
10 {:package, [:packages]},
11 {:first_name, [:first_names]},
12 {:last_name, [:last_names]},
13 {:username, [:usernames]},
14 {:word, [:words]},
15 {:email, [:usernames]},
16 {:full_name, [:first_names, :last_names]},
17 {:sentence, [:words]}
18 ]
19
20 def start() do
21 1 :ets.new(__MODULE__, [:named_table, :public, read_concurrency: true])
22
23 1 Enum.each(@files, &load_file/1)
24
25 1 Enum.each(@generators, fn {key, deps} ->
26 8 :ets.insert(__MODULE__, {key, 0})
27 8 size = Enum.map(deps, &:ets.lookup_element(__MODULE__, {&1, :size}, 2)) |> Enum.min()
28 8 :ets.insert(__MODULE__, {{key, :size}, size})
29 end)
30 end
31
32 4823 def sequence(key, opts \\ [])
33 3281 def random(key, opts \\ [])
34
35 Enum.each(@generators, fn {key, _deps} ->
36 def sequence(unquote(key), opts) do
37 1701 [{_key, size}] = :ets.lookup(__MODULE__, {unquote(key), :size})
38 1701 counter = :ets.update_counter(__MODULE__, unquote(key), {2, 1})
39 1701 opts = Keyword.put(opts, :num_objects, size)
40 1701 generator(unquote(key), counter, opts)
41 end
42
43 def random(unquote(key), opts) do
44 1638 [{_key, size}] = :ets.lookup(__MODULE__, {unquote(key), :size})
45 1638 counter = Enum.random(1..size) - 1
46 1638 opts = Keyword.put(opts, :num_objects, size)
47 1638 generator(unquote(key), counter, opts)
48 end
49 end)
50
51 defp load_file(name) do
52 5 seed = seed()
53 5 :rand.seed(:exrop, {seed, seed, seed})
54
55 5 path = Path.join(Application.app_dir(:hexpm, "priv/fake"), "#{name}.txt")
56
57 5 objects =
58 File.read!(path)
59 |> String.split("\n", trim: true)
60 |> Enum.shuffle()
61 |> Stream.with_index()
62 12406 |> Enum.map(fn {line, ix} -> {{name, ix}, line} end)
63
64 5 :ets.insert(__MODULE__, objects)
65 5 :ets.insert(__MODULE__, {{name, :size}, length(objects)})
66 end
67
68 defp seed() do
69 5 if Code.ensure_loaded?(ExUnit) do
70 5 ExUnit.configuration()[:seed]
71 else
72 0
73 end
74 end
75
76 defp get!(key, counter, original_key \\ nil) do
77 15358 case :ets.lookup(__MODULE__, {key, counter}) do
78 [{_key, value}] ->
79 15358 value
80
81 [] ->
82 0 raise "Ran out of fake data for #{original_key || key}"
83 end
84 end
85
86 2335 defp generator(:package, counter, _opts), do: get!(:packages, counter)
87 0 defp generator(:first_name, counter, _opts), do: get!(:first_names, counter)
88 0 defp generator(:last_name, counter, _opts), do: get!(:last_names, counter)
89 1806 defp generator(:username, counter, _opts), do: get!(:usernames, counter)
90 0 defp generator(:word, counter, _opts), do: get!(:words, counter)
91
92 defp generator(:email, counter, _opts) do
93 1701 username = get!(:usernames, counter)
94 1701 "#{username}@example.com"
95 end
96
97 defp generator(:full_name, counter, _opts) do
98 1638 first_name = get!(:first_names, counter)
99 1638 last_name = get!(:last_names, counter)
100 1638 "#{first_name} #{last_name}"
101 end
102
103 defp generator(:sentence, counter, opts) do
104 624 num = Keyword.get(opts, :size, 10)
105 624 num_objects = Keyword.fetch!(opts, :num_objects)
106 624 Enum.map_join(1..num, " ", &get!(:words, rem(counter + &1, num_objects)))
107 end
108 end

lib/hexpm/http.ex

0
14
0
14
Line Hits Source
0 defmodule Hexpm.HTTP do
1 require Logger
2
3 @max_retry_times 3
4 @base_sleep_time 100
5
6 def get(url, headers) do
7 :hackney.get(url, headers)
8 0 |> read_response()
9 end
10
11 def put(url, headers, body) do
12 :hackney.put(url, headers, body)
13 0 |> read_response()
14 end
15
16 def delete(url, headers) do
17 :hackney.delete(url, headers)
18 0 |> read_response()
19 end
20
21 defp read_response(result) do
22 0 with {:ok, status, headers, ref} <- result,
23 0 {:ok, body} <- :hackney.body(ref) do
24 0 {:ok, status, headers, body}
25 end
26 end
27
28 def retry(fun, name) do
29 0 retry(fun, name, 0)
30 end
31
32 defp retry(fun, name, times) do
33 0 case fun.() do
34 {:error, reason} ->
35 0 Logger.warn("#{name} API ERROR: #{inspect(reason)}")
36
37 0 if times + 1 < @max_retry_times do
38 0 sleep = trunc(:math.pow(3, times) * @base_sleep_time)
39 0 :timer.sleep(sleep)
40 0 retry(fun, name, times + 1)
41 else
42 {:error, reason}
43 end
44
45 result ->
46 0 result
47 end
48 end
49 end

lib/hexpm/pwned/hexpm.ex

0
11
0
11
Line Hits Source
0 defmodule Hexpm.Pwned.HaveIBeenPwned do
1 @behaviour Hexpm.Pwned
2
3 @base_url "https://api.pwnedpasswords.com/"
4 @weakness_threshold 1
5 @timeout 500
6
7 @spec password_breached?(String.t()) :: boolean
8 def password_breached?(string_password) do
9 string_password
10 |> hash_password()
11 |> occurrences_of_hash()
12 0 |> Kernel.>=(@weakness_threshold)
13 end
14
15 defp hash_password(string_password) do
16 :sha
17 |> :crypto.hash(string_password)
18 0 |> Base.encode16()
19 end
20
21 defp range(searchable_range) do
22 0 url = @base_url <> "range/#{searchable_range}"
23 0 headers = [{"User-Agent", "hexpm"}]
24
25 0 case :hackney.get(url, headers, "",
26 connect_timeout: @timeout,
27 recv_timeout: @timeout,
28 with_body: true
29 ) do
30 {:ok, 200, _headers, body} ->
31 0 String.split(body, "\r\n")
32
33 0 {:error, _} ->
34 []
35 end
36 end
37
38 defp occurrences_of_hash(<<searchable_range::bytes-5, remainder::binary>>) do
39 searchable_range
40 |> range()
41 0 |> Enum.map(&String.split(&1, ":"))
42 |> Enum.find_value("0", fn
43 0 [^remainder, occurrences] -> occurrences
44 0 _ -> nil
45 end)
46 0 |> String.to_integer()
47 end
48 end

lib/hexpm/pwned/local.ex

0
2
0
2
Line Hits Source
0 defmodule Hexpm.Pwned.Local do
1 @behaviour Hexpm.Pwned
2
3 @spec password_breached?(String.t()) :: boolean
4 0 def password_breached?("password"), do: true
5 0 def password_breached?(_), do: false
6 end

lib/hexpm/pwned/pwned.ex

100
2
24
0
Line Hits Source
0 defmodule Hexpm.Pwned do
1 @moduledoc """
2 This module acts as an interface to the haveibeenpwned API
3 https://haveibeenpwned.com/API/v2
4 """
5
6 @callback password_breached?(String.t()) :: boolean()
7
8 12 defp impl(), do: Application.get_env(:hexpm, :pwned_impl)
9
10 12 def password_breached?(password), do: impl().password_breached?(password)
11 end

lib/hexpm/release_tasks.ex

0
98
0
98
Line Hits Source
0 defmodule Hexpm.ReleaseTasks do
1 alias Hexpm.ReleaseTasks.{CheckNames, Stats}
2 require Logger
3
4 @repo_apps [
5 :crypto,
6 :ssl,
7 :postgrex,
8 :ecto_sql
9 ]
10
11 @repos Application.compile_env!(:hexpm, :ecto_repos)
12
13 def script(args) do
14 0 {:ok, _} = Application.ensure_all_started(:logger)
15 0 Logger.info("[task] Running script")
16 0 start_app()
17
18 0 task(fn -> run_script(args) end)
19
20 0 Logger.info("[task] Finished script")
21 0 stop()
22 end
23
24 def check_names() do
25 0 {:ok, _} = Application.ensure_all_started(:logger)
26 0 Logger.info("[task] Running check_names")
27 0 start_app()
28
29 0 task(&CheckNames.run/0)
30
31 0 Logger.info("[task] Finished check_names")
32 0 stop()
33 end
34
35 def migrate(args \\ []) do
36 0 {:ok, _} = Application.ensure_all_started(:logger)
37 0 Logger.info("[task] Running migrate")
38 0 start_repo()
39
40 0 task(fn -> run_migrations(args) end)
41
42 0 Logger.info("[task] Finished migrate")
43 0 stop()
44 end
45
46 def rollback(args \\ []) do
47 0 {:ok, _} = Application.ensure_all_started(:logger)
48 0 Logger.info("[task] Running rollback")
49 0 start_repo()
50
51 0 task(fn -> run_rollback(args) end)
52
53 0 Logger.info("[task] Finished rollback")
54 0 stop()
55 end
56
57 def seed(args \\ []) do
58 0 {:ok, _} = Application.ensure_all_started(:logger)
59 0 Logger.info("[task] Running seed")
60
61 0 task(fn ->
62 0 start_repo()
63 0 run_migrations(args)
64 0 run_seeds()
65 end)
66
67 0 Logger.info("[task] Finished seed")
68 0 stop()
69 end
70
71 def stats() do
72 0 {:ok, _} = Application.ensure_all_started(:logger)
73 0 Logger.info("[task] Running stats")
74 0 start_app()
75
76 0 task(&Stats.run/0)
77
78 0 Logger.info("[task] Finished stats")
79 0 stop()
80 end
81
82 0 defp task(fun) do
83 0 Process.flag(:trap_exit, true)
84
85 0 %Task{ref: ref} =
86 Task.async(fn ->
87 0 try do
88 0 fun.()
89 catch
90 kind, error ->
91 0 Rollbax.report(kind, error, __STACKTRACE__)
92 end
93 end)
94
95 0 receive do
96 0 {^ref, _result} ->
97 :ok
98
99 {:EXIT, _pid, {error, stacktrace}} ->
100 0 Rollbax.report(:error, error, stacktrace)
101 end
102 after
103 0 Process.flag(:trap_exit, false)
104 end
105
106 defp start_app() do
107 0 Logger.info("[task] Starting app...")
108 0 Application.put_env(:phoenix, :serve_endpoints, false, persistent: true)
109 0 Application.put_env(:hexpm, :topologies, [], persistent: true)
110 0 {:ok, _} = Application.ensure_all_started(:hexpm)
111 end
112
113 defp start_repo() do
114 0 Logger.info("[task] Starting dependencies...")
115
116 0 Enum.each(@repo_apps, fn app ->
117 0 {:ok, _} = Application.ensure_all_started(app)
118 end)
119
120 0 Logger.info("[task] Starting repos...")
121
122 0 Enum.each(@repos, fn repo ->
123 0 {:ok, _} = repo.start_link(pool_size: 2)
124 end)
125 end
126
127 defp stop() do
128 0 Logger.info("[task] Stopping...")
129 0 :init.stop()
130 end
131
132 defp run_migrations(args) do
133 0 Enum.each(@repos, fn repo ->
134 0 app = Keyword.get(repo.config(), :otp_app)
135 0 Logger.info("[task] Running migrations for #{app}")
136
137 0 case args do
138 0 ["--step", n] -> migrate(repo, :up, step: String.to_integer(n))
139 0 ["-n", n] -> migrate(repo, :up, step: String.to_integer(n))
140 0 ["--to", to] -> migrate(repo, :up, to: to)
141 0 ["--all"] -> migrate(repo, :up, all: true)
142 0 [] -> migrate(repo, :up, all: true)
143 end
144 end)
145 end
146
147 defp run_rollback(args) do
148 0 Enum.each(@repos, fn repo ->
149 0 app = Keyword.get(repo.config(), :otp_app)
150 0 Logger.info("[task] Running rollback for #{app}")
151
152 0 case args do
153 0 ["--step", n] -> migrate(repo, :down, step: String.to_integer(n))
154 0 ["-n", n] -> migrate(repo, :down, step: String.to_integer(n))
155 0 ["--to", to] -> migrate(repo, :down, to: to)
156 0 ["--all"] -> migrate(repo, :down, all: true)
157 0 [] -> migrate(repo, :down, step: 1)
158 end
159 end)
160 end
161
162 defp migrate(repo, direction, opts) do
163 0 migrations_path = priv_path_for(repo, "migrations")
164 0 Ecto.Migrator.run(repo, migrations_path, direction, opts)
165 end
166
167 defp run_seeds() do
168 0 Enum.each(@repos, &run_seeds_for/1)
169 end
170
171 defp run_seeds_for(repo) do
172 # Run the seed script if it exists
173 0 seed_script = priv_path_for(repo, "seeds.exs")
174
175 0 if File.exists?(seed_script) do
176 0 Logger.info("[task] Running seed script...")
177 0 Code.eval_file(seed_script)
178 end
179 end
180
181 defp priv_path_for(repo, filename) do
182 0 app = Keyword.get(repo.config(), :otp_app)
183 0 priv_dir = Application.app_dir(app, "priv")
184
185 0 Path.join([priv_dir, "repo", filename])
186 end
187
188 # TODO: Move all scripts to release tasks
189 defp run_script(args) do
190 0 [script | args] = args
191
192 0 priv_dir = Application.app_dir(:hexpm, "priv")
193 0 script_dir = Path.join(priv_dir, "scripts")
194 0 original_argv = System.argv()
195
196 0 Logger.info("[task] Running #{script} #{inspect(args)}")
197
198 0 try do
199 0 System.argv(args)
200 0 Code.eval_file(script, script_dir)
201 after
202 0 System.argv(original_argv)
203 end
204
205 0 Logger.info("[task] Finished #{script} #{inspect(args)}")
206 end
207 end

lib/hexpm/release_tasks/check_names.ex

14.3
14
2
12
Line Hits Source
0 defmodule Hexpm.ReleaseTasks.CheckNames do
1 require Logger
2
3 def run() do
4 # Trigger error_handler and rollbar reporting on 'hexpm eval ...'
5 Task.async(&do_run/0)
6 0 |> Task.await(:infinity)
7 end
8
9 0 def do_run() do
10 0 threshold = Application.get_env(:hexpm, :levenshtein_threshold)
11
12 threshold
13 |> to_integer()
14 |> find_candidates()
15 |> log_result()
16 0 |> send_email(threshold)
17 catch
18 exception ->
19 0 Logger.error("[check_names] failed")
20 0 reraise exception, __STACKTRACE__
21 end
22
23 defp log_result(candidates) do
24 0 Logger.info("[check_names] job found #{length(candidates)} candidates")
25 0 candidates
26 end
27
28 0 defp send_email([], _threshold), do: :ok
29
30 defp send_email(candidates, threshold) do
31 candidates
32 |> Hexpm.Emails.typosquat_candidates(threshold)
33 0 |> Hexpm.Emails.Mailer.deliver_later!()
34 end
35
36 def find_candidates(threshold) do
37 1 query = """
38 SELECT pnew.name new_name, pall.name curr_name, levenshtein(pall.name, pnew.name) as dist
39 FROM packages as pall
40 CROSS JOIN packages as pnew
41 WHERE pall.name <> pnew.name
42 AND pnew.inserted_at >= CURRENT_DATE AT TIME ZONE 'UTC'
43 AND levenshtein(pall.name, pnew.name) <= $1
44 ORDER BY pall.name, dist
45 """
46
47 Hexpm.Repo.query!(query, [threshold])
48 |> Map.fetch!(:rows)
49 1 |> Enum.uniq_by(fn [a, b, _] -> if a > b, do: "#{a}-#{b}", else: "#{b}-#{a}" end)
50 end
51
52 0 defp to_integer(int) when is_integer(int), do: int
53 0 defp to_integer(string) when is_binary(string), do: String.to_integer(string)
54 end

lib/hexpm/release_tasks/stats.ex

94.5
55
196
3
Line Hits Source
0 defmodule Hexpm.ReleaseTasks.Stats do
1 require Logger
2 import Ecto.Query, only: [from: 2]
3 alias Hexpm.{Repo, Store, Utils}
4
5 alias Hexpm.Repository.{
6 Download,
7 Package,
8 PackageDownload,
9 Release,
10 ReleaseDownload,
11 Repository
12 }
13
14 @fastly_regex ~r<
15 [^\040]+\040 # syslog
16 [^\040]+\040 # user
17 [^\040]+\040 # source
18 [^\040]+\040 # IP address
19 (?:(?:"[^"]+")|(?:\[[^\]]+\]))\040 # time
20 "GET\040/
21 (?:repos/([^/]+)/)? # repository
22 tarballs/
23 ([^-]+) # package
24 -
25 ([\d\w\.\-]+) # version
26 .tar
27 (?:\?[^\040"]*)?
28 (?:\040HTTP/\d\.\d)?
29 "\040
30 ([0-9]{3})\040 # status
31 >x
32
33 @ets __MODULE__
34
35 def run(date \\ Utils.utc_yesterday(), dryrun? \\ false) do
36 1 {time, size} =
37 :timer.tc(fn ->
38 1 do_run(date, dryrun?)
39 end)
40
41 1 Logger.info("[stats] completed #{size} downloads (#{div(time, 1000)}ms)")
42 end
43
44 1 def do_run(date, dryrun?) do
45 1 :ets.new(@ets, [:named_table, :public])
46
47 1 try do
48 1 process_buckets(date)
49 1 repositories = repositories()
50 1 packages = packages()
51 1 releases = releases()
52
53 # May not be a perfect count since it counts downloads without a release
54 # in the database. Should be uncommon
55 1 num = ets_stream() |> Enum.reduce(0, fn {_, count}, acc -> count + acc end)
56
57 1 unless dryrun? do
58 1 Repo.transaction(
59 fn ->
60 1 Repo.delete_all(from(d in Download, where: d.day == ^date))
61
62 ets_stream()
63 |> Stream.flat_map(fn {{repository, package, version}, count} ->
64 5 repository_id = repositories[repository]
65 5 package_id = packages[{repository_id, package}]
66
67 5 if release_id = releases[{package_id, version}] do
68 [%{package_id: package_id, release_id: release_id, downloads: count, day: date}]
69 else
70 []
71 end
72 end)
73 |> Stream.chunk_every(1000, 1000, [])
74 1 |> Enum.each(&Repo.insert_all(Download, &1))
75
76 1 Repo.refresh_view(PackageDownload)
77 1 Repo.refresh_view(ReleaseDownload)
78 end,
79 timeout: 120_000
80 )
81 end
82
83 1 num
84 after
85 1 :ets.delete(@ets)
86 end
87 catch
88 exception ->
89 0 Logger.error("[stats] failed")
90 0 reraise exception, __STACKTRACE__
91 end
92
93 def ets_stream() do
94 2 start_fun = fn -> :ets.first(@ets) end
95 2 after_fun = fn _ -> :ok end
96
97 2 next_fun = fn
98 2 :"$end_of_table" -> {:halt, nil}
99 10 key -> {:ets.lookup(@ets, key), :ets.next(@ets, key)}
100 end
101
102 2 Stream.resource(start_fun, next_fun, after_fun)
103 end
104
105 defp process_buckets(date) do
106 1 bucket = Application.get_env(:hexpm, :logs_bucket)
107 1 prefix = "fastly_hex/#{date}"
108 1 keys = Store.list(bucket, prefix) |> Enum.to_list()
109 1 process_keys(bucket, keys)
110 end
111
112 defp process_keys(bucket, keys) do
113 Task.async_stream(
114 keys,
115 fn key ->
116 Store.get(bucket, key, [])
117 |> maybe_unzip(key)
118 2 |> process_file()
119 end,
120 max_concurrency: 10,
121 timeout: 600_000
122 )
123 1 |> Stream.run()
124 end
125
126 defp process_file(file) do
127 2 lines = String.split(file, "\n")
128
129 2 Enum.each(lines, fn line ->
130 16 case parse_line(line) do
131 {repository, package, version} ->
132 12 key = {repository, package, version}
133 12 :ets.update_counter(@ets, key, 1, {key, 0})
134
135 4 nil ->
136 :ok
137 end
138 end)
139 end
140
141 defp parse_line(line) do
142 16 case Regex.run(@fastly_regex, line) do
143 [_, repository, package, version, status] when status in ~w(200 304) ->
144 12 {copy(nillify(repository)) || "hexpm", copy(package), copy(version)}
145
146 4 _ ->
147 nil
148 end
149 end
150
151 defp repositories() do
152 from(r in Repository, select: {r.name, r.id})
153 |> Repo.all()
154 1 |> Map.new()
155 end
156
157 defp packages() do
158 from(p in Package, select: {{p.repository_id, p.name}, p.id})
159 |> Repo.all()
160 1 |> Map.new()
161 end
162
163 defp releases() do
164 from(r in Release, select: {{r.package_id, r.version}, r.id})
165 |> Repo.all()
166 1 |> Map.new(fn {{pid, vsn}, rid} -> {{pid, to_string(vsn)}, rid} end)
167 end
168
169 defp maybe_unzip(data, key) do
170 2 if String.ends_with?(key, ".gz") do
171 2 :zlib.gunzip(data)
172 else
173 0 data
174 end
175 end
176
177 9 defp nillify(""), do: nil
178 3 defp nillify(binary), do: binary
179
180 9 defp copy(nil), do: nil
181 27 defp copy(binary), do: :binary.copy(binary)
182 end

lib/hexpm/repo.ex

63.0
54
16805
20
Line Hits Source
0 defmodule Hexpm.RepoHelpers do
1 defmacro defwrite({name, _meta, params}) do
2 quote do
3 def unquote(name)(unquote_splicing(params)) do
4 write_mode!()
5 Hexpm.RepoBase.unquote(name)(unquote_splicing(params_to_args(params)))
6 end
7 end
8 end
9
10 defp params_to_args(params) do
11 0 Enum.map(params, fn
12 0 {:\\, _meta, [arg, _default]} -> arg
13 0 arg -> arg
14 end)
15 end
16 end
17
18 defmodule Hexpm.Repo do
19 import Hexpm.RepoHelpers
20 alias Hexpm.RepoBase
21
22 37 defdelegate aggregate(queryable, aggregate, field, opts \\ []), to: RepoBase
23 619 defdelegate all(queryable, opts \\ []), to: RepoBase
24 34 defdelegate get_by!(queryable, clauses, opts \\ []), to: RepoBase
25 719 defdelegate get_by(queryable, clauses, opts \\ []), to: RepoBase
26 12 defdelegate get!(queryable, id, opts \\ []), to: RepoBase
27 177 defdelegate get(queryable, id, opts \\ []), to: RepoBase
28 223 defdelegate one!(queryable, opts \\ []), to: RepoBase
29 412 defdelegate one(queryable, opts \\ []), to: RepoBase
30 752 defdelegate preload(structs_or_struct_or_nil, preloads, opts \\ []), to: RepoBase
31
32 0 defwrite(try_advisory_xact_lock?(key, opts \\ []))
33 48 defwrite(try_advisory_lock?(key, opts \\ []))
34 48 defwrite(advisory_unlock(key, opts \\ []))
35 5 defwrite(delete_all(queryable, opts \\ []))
36 7 defwrite(delete!(struct_or_changeset, opts \\ []))
37 0 defwrite(delete(struct_or_changeset, opts \\ []))
38 4 defwrite(insert_all(queryable, opts \\ []))
39 0 defwrite(insert_or_update(changeset, opts \\ []))
40 3467 defwrite(insert!(struct_or_changeset, opts \\ []))
41 20 defwrite(insert(struct_or_changeset, opts \\ []))
42 1 defwrite(query!(sql, params \\ [], opts \\ []))
43 54 defwrite(query(sql, params \\ [], opts \\ []))
44 17 defwrite(refresh_view(schema))
45 0 defwrite(rollback(value))
46 406 defwrite(transaction(fun_or_multi, opts \\ []))
47 6 defwrite(update_all(queryable, opts \\ []))
48 275 defwrite(update!(changeset, opts \\ []))
49 3 defwrite(update(changeset, opts \\ []))
50
51 def write_mode?() do
52 4789 not Application.get_env(:hexpm, :read_only_mode, false)
53 end
54
55 def write_mode!() do
56 4361 unless write_mode?() do
57 1 raise Hexpm.WriteInReadOnlyMode
58 end
59 end
60 end
61
62 defmodule Hexpm.RepoBase do
63 use Ecto.Repo,
64 otp_app: :hexpm,
65 adapter: Ecto.Adapters.Postgres
66
67 @advisory_locks %{
68 registry: 1
69 }
70
71 def init(_reason, opts) do
72 1 if url = System.get_env("HEXPM_DATABASE_URL") do
73 0 pool_size_env = System.get_env("HEXPM_DATABASE_POOL_SIZE")
74 0 pool_size = if pool_size_env, do: String.to_integer(pool_size_env), else: opts[:pool_size]
75 0 ca_cert = System.get_env("HEXPM_DATABASE_CA_CERT")
76 0 client_key = System.get_env("HEXPM_DATABASE_CLIENT_KEY")
77 0 client_cert = System.get_env("HEXPM_DATABASE_CLIENT_CERT")
78
79 0 ssl_opts =
80 0 if ca_cert do
81 [
82 cacerts: [decode_cert(ca_cert)],
83 key: decode_key(client_key),
84 cert: decode_cert(client_cert)
85 ]
86 end
87
88 0 opts =
89 opts
90 |> Keyword.put(:ssl_opts, ssl_opts)
91 |> Keyword.put(:url, url)
92 |> Keyword.put(:pool_size, pool_size)
93
94 {:ok, opts}
95 else
96 {:ok, opts}
97 end
98 end
99
100 defp decode_cert(cert) do
101 0 [{:Certificate, der, _}] = :public_key.pem_decode(cert)
102 0 der
103 end
104
105 defp decode_key(cert) do
106 0 [{:RSAPrivateKey, key, :not_encrypted}] = :public_key.pem_decode(cert)
107 {:RSAPrivateKey, key}
108 end
109
110 def refresh_view(schema) do
111 54 source = schema.__schema__(:source)
112 54 query = ~s(REFRESH MATERIALIZED VIEW CONCURRENTLY "#{source}")
113
114 54 {:ok, _} = Hexpm.Repo.query(query, [])
115 :ok
116 end
117
118 def try_advisory_xact_lock?(key, opts \\ []) do
119 0 %Postgrex.Result{rows: [[result]]} =
120 query!(
121 "SELECT pg_try_advisory_xact_lock($1)",
122 [Map.fetch!(@advisory_locks, key)],
123 opts
124 )
125
126 0 result
127 end
128
129 def try_advisory_lock?(key, opts \\ []) do
130 48 %Postgrex.Result{rows: [[result]]} =
131 query!(
132 "SELECT pg_try_advisory_lock($1)",
133 [Map.fetch!(@advisory_locks, key)],
134 opts
135 )
136
137 48 result
138 end
139
140 def advisory_unlock(key, opts \\ []) do
141 48 %Postgrex.Result{rows: [[true]]} =
142 query!(
143 "SELECT pg_advisory_unlock($1)",
144 [Map.fetch!(@advisory_locks, key)],
145 opts
146 )
147
148 :ok
149 end
150 end
151
152 defmodule Hexpm.WriteInReadOnlyMode do
153 defexception []
154
155 1 def message(_) do
156 "tried to write in read-only mode"
157 end
158 end

lib/hexpm/repository/assets.ex

100
30
699
0
Line Hits Source
0 defmodule Hexpm.Repository.Assets do
1 alias Hexpm.Repository.Repository
2
3 def push_release(release, body) do
4 28 meta = [
5 {"surrogate-key", tarball_cdn_key(release)},
6 {"surrogate-control", "public, max-age=604800"}
7 ]
8
9 28 cache_control = tarball_cache_control(release.package.repository)
10 28 opts = [cache_control: cache_control, meta: meta]
11
12 28 Hexpm.Store.put(:repo_bucket, tarball_store_key(release), body, opts)
13 28 Hexpm.CDN.purge_key(:fastly_hexrepo, tarball_cdn_key(release))
14 end
15
16 def revert_release(release) do
17 9 Hexpm.CDN.purge_key(:fastly_hexrepo, tarball_cdn_key(release))
18 9 Hexpm.Store.delete(:repo_bucket, tarball_store_key(release))
19 9 revert_docs(release)
20 end
21
22 def push_docs(release, body) do
23 7 meta = [
24 {"surrogate-key", docs_cdn_key(release)},
25 {"surrogate-control", "public, max-age=604800"}
26 ]
27
28 7 cache_control = docs_cache_control(release.package.repository)
29 7 opts = [cache_control: cache_control, meta: meta]
30
31 7 Hexpm.Store.put(:repo_bucket, docs_store_key(release), body, opts)
32 7 Hexpm.CDN.purge_key(:fastly_hexrepo, docs_cdn_key(release))
33 end
34
35 def revert_docs(release) do
36 11 if release.has_docs do
37 5 Hexpm.Store.delete(:repo_bucket, docs_store_key(release))
38 5 Hexpm.CDN.purge_key(:fastly_hexrepo, docs_cdn_key(release))
39 end
40 end
41
42 20 defp tarball_cache_control(%Repository{id: 1}), do: "public, max-age=604800"
43 8 defp tarball_cache_control(%Repository{}), do: "private, max-age=86400"
44
45 3 defp docs_cache_control(%Repository{id: 1}), do: "public, max-age=86400"
46 4 defp docs_cache_control(%Repository{}), do: "private, max-age=86400"
47
48 def tarball_cdn_key(release) do
49 65 "tarballs/#{repository_cdn_key(release)}#{release.package.name}-#{release.version}"
50 end
51
52 def tarball_store_key(release) do
53 37 "#{repository_store_key(release)}tarballs/#{release.package.name}-#{release.version}.tar"
54 end
55
56 def docs_cdn_key(release) do
57 19 "docs/#{repository_cdn_key(release)}#{release.package.name}-#{release.version}"
58 end
59
60 def docs_store_key(release) do
61 12 "#{repository_store_key(release)}docs/#{release.package.name}-#{release.version}.tar.gz"
62 end
63
64 defp repository_cdn_key(release) do
65 84 repository = release.package.repository
66
67 84 if repository.id == 1 do
68 ""
69 else
70 27 "#{repository.name}-"
71 end
72 end
73
74 defp repository_store_key(release) do
75 49 repository = release.package.repository
76
77 49 if repository.id == 1 do
78 ""
79 else
80 15 "repos/#{repository.name}/"
81 end
82 end
83 end

lib/hexpm/repository/download.ex

100
5
619
0
Line Hits Source
0 defmodule Hexpm.Repository.Download do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4
5 585 schema "downloads" do
6 belongs_to :package, Package
7 belongs_to :release, Release
8 field :downloads, :integer
9 field :day, :date
10 field :updated_at, :utc_datetime_usec, virtual: true
11 end
12
13 defmacrop date_trunc(period, expr) do
14 quote do
15 fragment("date_trunc(?, ?)", unquote(period), unquote(expr))
16 end
17 end
18
19 defmacrop date_trunc_format(period, format, expr) do
20 quote do
21 fragment("to_char(date_trunc(?, ?), ?)", unquote(period), unquote(expr), unquote(format))
22 end
23 end
24
25 def query_filter(query, filter) do
26 17 case filter do
27 :day ->
28 10 from(
29 d in query,
30 group_by: d.day,
31 order_by: d.day,
32 select: %Download{
33 day: date_trunc_format("day", "YYYY-MM-DD", d.day),
34 downloads: sum(d.downloads),
35 updated_at: max(d.day)
36 }
37 )
38
39 :month ->
40 1 from(
41 d in query,
42 group_by: date_trunc("month", d.day),
43 order_by: date_trunc("month", d.day),
44 select: %Download{
45 day: date_trunc_format("month", "YYYY-MM", d.day),
46 downloads: sum(d.downloads),
47 updated_at: max(d.day)
48 }
49 )
50
51 :all ->
52 6 from(
53 d in query,
54 select: %Download{
55 downloads: sum(d.downloads),
56 updated_at: max(d.day)
57 }
58 )
59 end
60 end
61 end

lib/hexpm/repository/install.ex

92.9
14
202
1
Line Hits Source
0 defmodule Hexpm.Repository.Install do
1 use Hexpm.Schema
2
3 54 schema "installs" do
4 field :hex, :string
5 field :elixirs, {:array, :string}
6 end
7
8 def all() do
9 9 from(i in Install, order_by: [asc: i.id])
10 end
11
12 def latest(all, current) do
13 9 case Version.parse(current) do
14 {:ok, current} ->
15 9 installs =
16 Enum.filter(all, fn %Install{elixirs: elixirs} ->
17 54 Enum.any?(elixirs, &(Version.compare(&1, current) != :gt))
18 end)
19
20 9 elixir =
21 1 if install = List.last(installs) do
22 8 install.elixirs
23 18 |> Enum.filter(&(Version.compare(&1, current) != :gt))
24 8 |> List.last()
25 end
26
27 9 if elixir do
28 8 {:ok, install.hex, elixir}
29 else
30 :error
31 end
32
33 0 :error ->
34 :error
35 end
36 end
37
38 def build(hex, elixirs) do
39 6 change(%Hexpm.Repository.Install{}, hex: hex, elixirs: elixirs)
40 end
41 end

lib/hexpm/repository/installs.ex

100
1
9
0
Line Hits Source
0 defmodule Hexpm.Repository.Installs do
1 use Hexpm.Context
2
3 def all() do
4 9 Repo.all(Install.all())
5 end
6 end

lib/hexpm/repository/owners.ex

97.7
44
350
1
Line Hits Source
0 defmodule Hexpm.Repository.Owners do
1 use Hexpm.Context
2
3 def all(package, preload \\ []) do
4 assoc(package, :package_owners)
5 |> Repo.all()
6 40 |> Repo.preload(preload)
7 end
8
9 def get(package, user) do
10 25 if owner = Repo.get_by(PackageOwner, package_id: package.id, user_id: user.id) do
11 9 %{owner | package: package, user: user}
12 end
13 end
14
15 def add(package, user, params, audit: audit_data) do
16 12 repository = package.repository
17 12 owners = all(package, user: [:emails, :organization])
18 12 repository_access = Organizations.access?(repository.organization, user, "read")
19
20 12 cond do
21 12 repository.id != 1 and not repository_access ->
22 {:error, :not_member}
23
24 11 User.organization?(user) and Map.get(params, "transfer", false) != true ->
25 {:error, :not_organization_transfer}
26
27 10 User.organization?(user) and Map.get(params, "level", "full") != "full" ->
28 {:error, :organization_level}
29
30 10 not User.organization?(user) && Organizations.get(user.username) ->
31 {:error, :organization_user_conflict}
32
33 10 true ->
34 10 add_owner(package, owners, user, params, audit_data)
35 end
36 end
37
38 defp add_owner(package, owners, user, params, audit_data) do
39 10 owner = Enum.find(owners, &(&1.user_id == user.id))
40 10 owner = owner || %PackageOwner{package_id: package.id, user_id: user.id}
41 10 changeset = PackageOwner.changeset(owner, params)
42
43 10 multi =
44 Multi.new()
45 |> Multi.insert_or_update(:owner, changeset)
46 |> remove_existing_owners(owners, params)
47 |> audit(audit_data, add_owner_audit_log_action(params), fn %{owner: owner} ->
48 10 {package, owner.level, user}
49 end)
50
51 10 case Repo.transaction(multi) do
52 {:ok, %{owner: owner}} ->
53 # TODO: Separate email for the affected person
54 10 owners =
55 owners
56 11 |> Enum.map(& &1.user)
57 |> Kernel.++([user])
58 |> Repo.preload(organization: [organization_users: [user: :emails]])
59
60 Emails.owner_added(package, owners, user)
61 10 |> Mailer.deliver_later!()
62
63 {:ok, %{owner | user: user}}
64
65 0 {:error, :owner, changeset, _} ->
66 {:error, changeset}
67 end
68 end
69
70 2 defp add_owner_audit_log_action(%{"transfer" => true}), do: "owner.transfer"
71 8 defp add_owner_audit_log_action(_params), do: "owner.add"
72
73 defp remove_existing_owners(multi, owners, %{"transfer" => true}) do
74 2 Multi.run(multi, :removed_owners, fn repo, %{owner: owner} ->
75 2 owner_ids =
76 owners
77 2 |> Enum.filter(&(&1.id != owner.id))
78 2 |> Enum.map(& &1.id)
79
80 2 {num_rows, _} =
81 from(po in PackageOwner, where: po.id in ^owner_ids)
82 |> repo.delete_all()
83
84 {:ok, num_rows}
85 end)
86 end
87
88 defp remove_existing_owners(multi, _owners, _params) do
89 8 multi
90 end
91
92 def remove(package, user, audit: audit_data) do
93 4 owners = all(package, user: :emails)
94 4 owner = Enum.find(owners, &(&1.user_id == user.id))
95
96 4 cond do
97 4 !owner ->
98 {:error, :not_owner}
99
100 4 length(owners) == 1 and package.repository.id == 1 ->
101 {:error, :last_owner}
102
103 3 true ->
104 3 multi =
105 Multi.new()
106 |> Multi.delete(:owner, owner)
107 |> audit(audit_data, "owner.remove", fn %{owner: owner} ->
108 3 {package, owner.level, owner.user}
109 end)
110
111 3 {:ok, _} = Repo.transaction(multi)
112
113 # TODO: Separate email for the affected person
114 3 owners =
115 owners
116 5 |> Enum.map(& &1.user)
117 |> Repo.preload(organization: [users: :emails])
118
119 3 Emails.owner_removed(package, owners, owner.user)
120 3 |> Mailer.deliver_later!()
121
122 :ok
123 end
124 end
125 end

lib/hexpm/repository/package.ex

91.8
97
6109
8
Line Hits Source
0 defmodule Hexpm.Repository.Package do
1 use Hexpm.Schema
2 import Ecto.Query, only: [from: 2]
3
4 @derive {HexpmWeb.Stale, assocs: [:releases, :owners, :downloads]}
5 @derive {Phoenix.Param, key: :name}
6
7 4329 schema "packages" do
8 field :name, :string
9 field :docs_updated_at, :utc_datetime_usec
10 field :latest_version, Hexpm.Version, virtual: true
11 timestamps()
12
13 belongs_to :repository, Repository
14 has_many :releases, Release
15 has_many :package_owners, PackageOwner
16 has_many :owners, through: [:package_owners, :user]
17 has_many :downloads, PackageDownload
18 has_many :package_reports, PackageReport
19 embeds_one :meta, PackageMetadata, on_replace: :delete
20 end
21
22 @elixir_names ~w(eex elixir elixirc ex_unit iex logger mix)
23 @tool_names ~w(erlang typer to_erl run_erl escript erlc erl epmd dialyzer ct_run rebar rebar3 hex hexpm mix_hex)
24 @otp_names ~w(
25 otp asn1 common_test compiler crypto debugger dialyzer diameter
26 edoc eldap erl_docgen erl_interface erts et eunit ftp hipe
27 inets jinterface kernel megaco mnesia observer odbc os_mon
28 parsetools public_key reltool runtime_tools sasl snmp ssh
29 ssl stdlib syntax_tools toolbar tools typer wx xmerl
30 )
31 @inets_names ~w(tftp httpc httpd)
32 @app_names ~w(toucan net http net_http)
33 @windows_names ~w(
34 nul con prn aux com1 com2 com3 com4 com5 com6 com7 com8 com9 lpt1 lpt2
35 lpt3 lpt4 lpt5 lpt6 lpt7 lpt8 lpt9
36 )
37 @generic_names ~w(package organization www myapp lock locked)
38
39 @reserved_names Enum.concat([
40 @elixir_names,
41 @otp_names,
42 @inets_names,
43 @tool_names,
44 @app_names,
45 @windows_names,
46 @generic_names
47 ])
48
49 def build(repository, user, params) do
50 29 package =
51 build_assoc(repository, :packages)
52 |> Map.put(:repository, repository)
53
54 package
55 |> cast(params, ~w(name)a)
56 |> unique_constraint(:name, name: :packages_repository_id_name_index)
57 |> validate_required(:name)
58 |> validate_length(:name, min: 2)
59 |> validate_format(:name, ~r"^[a-z]\w*$")
60 |> validate_exclusion(:name, @reserved_names)
61 29 |> cast_embed(:meta, with: &PackageMetadata.changeset(&1, &2, package), required: true)
62 29 |> put_first_owner(user, repository)
63 end
64
65 @spec delete({map, any} | %{__struct__: atom | %{__changeset__: any}}) :: Ecto.Changeset.t()
66 def delete(package) do
67 7 foreign_key_constraint(
68 change(package),
69 :name,
70 name: "requirements_dependency_id_fkey",
71 message: "you cannot delete this package because other packages depend on it"
72 )
73 end
74
75 defp put_first_owner(changeset, user, repository) do
76 29 if repository.id == 1 do
77 16 put_assoc(changeset, :package_owners, [%PackageOwner{user_id: user.id}])
78 else
79 13 changeset
80 end
81 end
82
83 def update(package, params) do
84 cast(package, params, [])
85 # A release publish should always update the package's updated_at
86 |> force_change(:updated_at, DateTime.utc_now())
87 27 |> cast_embed(:meta, with: &PackageMetadata.changeset(&1, &2, package), required: true)
88 27 |> validate_metadata_name()
89 end
90
91 def package_owner(package, user, level \\ "maintainer") do
92 79 levels = PackageOwner.level_or_higher(level)
93
94 79 from(
95 po in PackageOwner,
96 left_join: ou in OrganizationUser,
97 79 on: ou.organization_id == ^package.repository.organization_id,
98 79 where: ou.user_id == ^user.id or ^(package.repository.id == 1),
99 79 where: po.package_id == ^package.id,
100 79 where: po.user_id == ^user.id,
101 where: po.level in ^levels,
102 select: count(po.id) >= 1
103 )
104 end
105
106 def organization_owner(package, user, level \\ "maintainer") do
107 24 role = PackageOwner.level_to_organization_role(level)
108 24 roles = Organization.role_or_higher(role)
109
110 24 from(
111 po in PackageOwner,
112 join: u in assoc(po, :user),
113 join: ou in OrganizationUser,
114 on: u.organization_id == ou.organization_id,
115 24 where: po.package_id == ^package.id,
116 24 where: ou.user_id == ^user.id,
117 where: ou.role in ^roles,
118 select: count(po.id) >= 1
119 )
120 end
121
122 def all(repositories, page, count, search, sort, fields) do
123 39 from(
124 p in assoc(repositories, :packages),
125 join: r in assoc(p, :repository),
126 preload: :downloads
127 )
128 |> sort(sort)
129 |> Hexpm.Utils.paginate(page, count)
130 |> search(search)
131 39 |> fields(fields)
132 end
133
134 def recent(repository, count) do
135 2 from(
136 p in assoc(repository, :packages),
137 order_by: [desc: p.inserted_at],
138 limit: ^count,
139 select: {p.name, p.inserted_at, p.meta}
140 )
141 end
142
143 def count() do
144 2 from(p in Package, select: count(p.id))
145 end
146
147 def count(repositories, search) do
148 17 from(
149 p in assoc(repositories, :packages),
150 join: r in assoc(p, :repository),
151 select: count(p.id)
152 )
153 17 |> search(search)
154 end
155
156 defp validate_metadata_name(changeset) do
157 27 name = get_field(changeset, :name)
158 27 meta_name = changeset.params["meta"]["name"]
159
160 27 if !meta_name || name == meta_name do
161 26 changeset
162 else
163 1 add_error(changeset, :name, "metadata does not match package name")
164 end
165 end
166
167 defp fields(query, nil) do
168 30 query
169 end
170
171 defp fields(query, fields) do
172 9 from(p in query, select: ^fields)
173 end
174
175 defmacrop description_query(p, search) do
176 quote do
177 fragment(
178 "to_tsvector('english', regexp_replace((?->'description')::text, '/', ' ')) @@ to_tsquery('english', ?)",
179 unquote(p).meta,
180 ^unquote(search)
181 )
182 end
183 end
184
185 defmacrop name_query(p, search) do
186 quote do
187 ilike(fragment("?::text", unquote(p).name), ^unquote(search))
188 end
189 end
190
191 defp search(query, nil) do
192 14 query
193 end
194
195 defp search(query, {:letter, letter}) do
196 4 search = letter <> "%"
197 4 from(p in query, where: name_query(p, search))
198 end
199
200 defp search(query, search) when is_binary(search) do
201 38 case parse_search(search) do
202 {:ok, params} ->
203 25 Enum.reduce(params, query, fn {k, v}, q -> search_param(k, v, q) end)
204
205 :error ->
206 13 basic_search(query, search)
207 end
208 end
209
210 defp basic_search(query, search) do
211 13 {repository, package} = name_search(search)
212 13 description = description_search(search)
213
214 13 if repository do
215 3 from(
216 [p, r] in query,
217 where:
218 (name_query(p, package) and name_query(r, repository)) or
219 description_query(p, description)
220 )
221 else
222 10 from(p in query, where: name_query(p, package) or description_query(p, description))
223 end
224 end
225
226 # TODO: add repository param
227 defp search_param("name", search, query) do
228 4 case String.split(search, "/", parts: 2) do
229 [repository, package] ->
230 0 from(
231 [p, r] in query,
232 where: name_query(p, extra_name_search(package)),
233 where: name_query(r, extra_name_search(repository))
234 )
235
236 _ ->
237 4 search = extra_name_search(search)
238 4 from(p in query, where: name_query(p, search))
239 end
240 end
241
242 defp search_param("description", search, query) do
243 0 search = description_search(search)
244 0 from(p in query, where: description_query(p, search))
245 end
246
247 defp search_param("extra", search, query) do
248 3 [value | keys] =
249 search
250 |> String.split(",")
251 |> Enum.reverse()
252
253 3 extra = extra_map(keys, extra_value(value))
254
255 3 from(p in query, where: fragment("?->'extra' @> ?", p.meta, ^extra))
256 end
257
258 defp search_param("depends", search, query) do
259 22 case String.split(search, ":", parts: 2) do
260 [repository, package] ->
261 22 from(
262 p in query,
263 join: pd in Hexpm.Repository.PackageDependant,
264 on: p.id == pd.dependant_id,
265 where: pd.name == ^package,
266 where: pd.repo == ^repository
267 )
268
269 _ ->
270 0 from(
271 p in query,
272 join: pd in Hexpm.Repository.PackageDependant,
273 on: p.id == pd.dependant_id,
274 where: pd.name == ^search
275 )
276 end
277 end
278
279 defp search_param(_, _, query) do
280 0 query
281 end
282
283 defp extra_value(<<"[", value::binary>>) do
284 value
285 |> String.trim_trailing("]")
286 |> String.split(",")
287 2 |> Enum.map(&try_integer/1)
288 end
289
290 1 defp extra_value(value), do: try_integer(value)
291
292 defp try_integer(string) do
293 3 case Integer.parse(string) do
294 1 {int, ""} -> int
295 2 _ -> string
296 end
297 end
298
299 3 defp extra_map([], m), do: m
300
301 defp extra_map([h | t], m) do
302 4 extra_map(t, %{h => m})
303 end
304
305 16 defp like_search(search, :contains), do: "%" <> search <> "%"
306 0 defp like_search(search, :equals), do: search
307
308 defp escape_search(search) do
309 20 String.replace(search, ~r"(%|_|\\)"u, "\\\\\\1")
310 end
311
312 defp name_search(search) do
313 13 case String.split(search, "/", parts: 2) do
314 3 [repository, package] ->
315 {do_name_search(repository), do_name_search(package)}
316
317 10 _ ->
318 {nil, do_name_search(search)}
319 end
320 end
321
322 defp do_name_search(search) do
323 search
324 |> escape_search()
325 16 |> like_search(search_filter(search))
326 end
327
328 defp search_filter(search) do
329 16 if String.length(search) >= 3 do
330 :contains
331 else
332 :equals
333 end
334 end
335
336 defp description_search(search) do
337 search
338 |> String.replace(~r/\//u, " ")
339 |> String.replace(~r/[^\w\s]/u, "")
340 |> String.trim()
341 13 |> String.replace(~r"\s+"u, " | ")
342 end
343
344 def extra_name_search(search) do
345 search
346 |> escape_search()
347 4 |> String.replace(~r/(^\*)|(\*$)/u, "%")
348 end
349
350 defp sort(query, :name) do
351 10 from(p in query, order_by: p.name)
352 end
353
354 defp sort(query, :inserted_at) do
355 1 from(p in query, order_by: [desc: p.inserted_at])
356 end
357
358 defp sort(query, :updated_at) do
359 1 from(p in query, order_by: [desc: p.updated_at])
360 end
361
362 defp sort(query, :total_downloads) do
363 1 from(
364 p in query,
365 left_join: d in PackageDownload,
366 on: p.id == d.package_id and d.view == "all",
367 order_by: [fragment("? DESC NULLS LAST", d.downloads)]
368 )
369 end
370
371 defp sort(query, :recent_downloads) do
372 18 from(
373 p in query,
374 left_join: d in PackageDownload,
375 on: p.id == d.package_id and d.view == "recent",
376 order_by: [fragment("? DESC NULLS LAST", d.downloads)]
377 )
378 end
379
380 defp sort(query, nil) do
381 8 query
382 end
383
384 defp parse_search(search) do
385 search
386 |> String.trim_leading()
387 38 |> parse_params([])
388 end
389
390 25 defp parse_params("", params), do: {:ok, Enum.reverse(params)}
391
392 defp parse_params(tail, params) do
393 42 with {:ok, key, tail} <- parse_key(tail),
394 29 {:ok, value, tail} <- parse_value(tail) do
395 29 parse_params(tail, [{key, value} | params])
396 else
397 _ -> :error
398 end
399 end
400
401 defp parse_key(string) do
402 42 with [k, tail] when k != "" <- String.split(string, ":", parts: 2) do
403 29 {:ok, k, String.trim_leading(tail)}
404 end
405 end
406
407 defp parse_value(string) do
408 29 case string do
409 "\"" <> rest ->
410 0 with [v, tail] <- String.split(rest, "\"", parts: 2) do
411 0 {:ok, v, String.trim_leading(tail)}
412 end
413
414 _ ->
415 29 case String.split(string, " ", parts: 2) do
416 25 [value] -> {:ok, value, ""}
417 4 [value, tail] -> {:ok, value, String.trim_leading(tail)}
418 end
419 end
420 end
421
422 def downloads_for_last_n_days(package_id, num_of_days) do
423 5 date_start = Date.add(Date.utc_today(), -1 * num_of_days)
424 5 from(d in downloads_by_period(package_id, :day), where: d.day >= ^date_start)
425 end
426
427 def downloads_by_period(package_id, filter) do
428 from(d in Download, where: d.package_id == ^package_id)
429 5 |> Download.query_filter(filter)
430 end
431 end

lib/hexpm/repository/package_dependant.ex

100
1
61
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageDependant do
1 use Hexpm.Schema
2
3 61 schema "package_dependants" do
4 belongs_to :package, Package
5 field :name, :string
6 field :repo, :string
7 end
8 end

lib/hexpm/repository/package_download.ex

80
10
681
2
Line Hits Source
0 defmodule Hexpm.Repository.PackageDownload do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @primary_key false
5
6 633 schema "package_downloads" do
7 belongs_to(:package, Package, references: :id)
8 field :view, :string
9 field :downloads, :integer
10 end
11
12 def top(repository, view, count) do
13 2 from(
14 pd in PackageDownload,
15 join: p in assoc(pd, :package),
16 2 where: p.repository_id == ^repository.id,
17 where: pd.view == ^view,
18 order_by: [fragment("? DESC NULLS LAST", pd.downloads)],
19 limit: ^count,
20 select: {p, pd.downloads}
21 )
22 end
23
24 def total() do
25 2 from(
26 pd in PackageDownload,
27 where: is_nil(pd.package_id),
28 select: {pd.view, coalesce(pd.downloads, 0)}
29 )
30 end
31
32 def package(package) do
33 9 from(
34 pd in PackageDownload,
35 9 where: pd.package_id == ^package.id,
36 select: {pd.view, coalesce(pd.downloads, 0)}
37 )
38 end
39
40 def packages_and_all_download_views(packages) do
41 12 package_ids = Enum.map(packages, & &1.id)
42
43 12 from(
44 pd in PackageDownload,
45 join: p in assoc(pd, :package),
46 where: pd.package_id in ^package_ids,
47 select: {p.id, pd.view, coalesce(pd.downloads, 0)}
48 )
49 end
50
51 def packages(packages, view) do
52 0 package_ids = Enum.map(packages, & &1.id)
53
54 0 from(
55 pd in PackageDownload,
56 join: p in assoc(pd, :package),
57 where: pd.package_id in ^package_ids,
58 where: pd.view == ^view,
59 select: {p.id, pd.downloads}
60 )
61 end
62 end

lib/hexpm/repository/package_metadata.ex

100
9
3832
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageMetadata do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4
5 3588 embedded_schema do
6 field :description, :string
7 field :licenses, {:array, :string}
8 field :links, {:map, :string}
9 field :maintainers, {:array, :string}
10 field :extra, :map
11 end
12
13 def changeset(meta, params, package) do
14 cast(meta, params, ~w(description licenses links maintainers extra)a)
15 |> validate_required_meta(package)
16 56 |> validate_links()
17 end
18
19 defp validate_required_meta(changeset, package) do
20 56 if package.repository.id == 1 do
21 35 validate_required(changeset, ~w(description licenses)a)
22 else
23 21 changeset
24 end
25 end
26
27 defp validate_links(changeset) do
28 56 validate_change(changeset, :links, fn _, links ->
29 links
30 |> Map.values()
31 |> Enum.reject(&valid_url?/1)
32 4 |> Enum.map(&{:links, "invalid link #{inspect(&1)}"})
33 end)
34 end
35
36 defp valid_url?(url) do
37 8 uri = URI.parse(url)
38 8 uri.scheme in ["http", "https"] and !!uri.host
39 end
40 end

lib/hexpm/repository/package_owner.ex

100
6
2359
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageOwner do
1 use Hexpm.Schema
2
3 2141 schema "package_owners" do
4 field :level, :string, default: "full"
5
6 belongs_to :package, Package
7 belongs_to :user, User
8
9 timestamps()
10 end
11
12 @valid_levels ["full", "maintainer"]
13
14 def changeset(package_owner, params) do
15 cast(package_owner, params, [:level])
16 |> unique_constraint(:user_id, name: "package_owners_unique", message: "is already owner")
17 |> validate_required(:level)
18 10 |> validate_inclusion(:level, @valid_levels)
19 end
20
21 89 def level_to_organization_role("maintainer"), do: "write"
22 40 def level_to_organization_role("full"), do: "admin"
23
24 54 def level_or_higher("maintainer"), do: ["maintainer", "full"]
25 25 def level_or_higher("full"), do: ["full"]
26 end

lib/hexpm/repository/package_report.ex

100
6
2131
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageReport do
1 use Hexpm.Schema
2
3 @derive Phoenix.Param
4
5 2075 schema "package_reports" do
6 field :state, :string, default: "to_accept"
7 field :description, :string
8
9 belongs_to :author, Hexpm.Accounts.User
10 belongs_to :package, Package
11 # field :requirement, :string
12 has_many :package_report_releases, PackageReportRelease
13 has_many :releases, through: [:package_report_releases, :release]
14
15 timestamps()
16 end
17
18 @valid_states ["to_accept", "accepted", "rejected", "solved", "unresolved"]
19
20 def build(releases, user, package, params) do
21 %PackageReport{}
22 |> cast(params, ~w(state description)a)
23 |> validate_required(:state)
24 |> validate_inclusion(:state, @valid_states)
25 |> put_assoc(:package_report_releases, package_report_releases(releases))
26 |> put_assoc(:author, user)
27 6 |> put_assoc(:package, package)
28 end
29
30 def change_state(report, params) do
31 cast(report, params, ~w(state)a)
32 |> validate_required(:state)
33 8 |> validate_inclusion(:state, @valid_states)
34 end
35
36 def get(id) do
37 35 from(
38 r in PackageReport,
39 preload: :author,
40 preload: :package,
41 preload: :releases,
42 preload: :package_report_releases,
43 where: r.id == ^id,
44 select: r
45 )
46 end
47
48 def all() do
49 1 from(
50 p in PackageReport,
51 preload: :package_report_releases,
52 preload: :author,
53 preload: :releases,
54 preload: :package,
55 order_by: [desc: p.updated_at]
56 )
57 end
58
59 defp package_report_releases(releases) do
60 6 Enum.map(releases, &%PackageReportRelease{release_id: &1.id})
61 end
62 end

lib/hexpm/repository/package_report_comment.ex

100
3
31
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageReportComment do
1 use Hexpm.Schema
2 import Ecto.Query, only: [from: 2]
3
4 15 schema "package_report_comments" do
5 field :text, :string
6 timestamps()
7
8 belongs_to :package_report, PackageReport
9 belongs_to :author, User
10 end
11
12 def build(report, user, params) do
13 %PackageReportComment{}
14 |> cast(params, ~w(text)a)
15 |> validate_required(:text)
16 |> validate_required(:text, min: 2)
17 |> put_assoc(:author, user)
18 1 |> put_assoc(:package_report, report)
19 end
20
21 def all_for_report(report_id) do
22 15 from(
23 c in PackageReportComment,
24 join: r in assoc(c, :package_report),
25 preload: :author,
26 where: r.id == ^report_id,
27 select: c
28 )
29 end
30 end

lib/hexpm/repository/package_report_release.ex

100
1
745
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageReportRelease do
1 use Hexpm.Schema
2
3 745 schema "package_report_releases" do
4 belongs_to :release, Release
5 belongs_to :package_report, PackageReport
6
7 timestamps()
8 end
9 end

lib/hexpm/repository/package_reports.ex

100
44
232
0
Line Hits Source
0 defmodule Hexpm.Repository.PackageReports do
1 use Hexpm.Context
2
3 def add(params) do
4 6 package_report =
5 Repo.insert!(
6 PackageReport.build(
7 params["releases"],
8 params["user"],
9 params["package"],
10 params
11 )
12 )
13
14 6 Enum.each(Users.get_by_role("moderator"), &email_new_report(package_report, &1))
15
16 6 package_report
17 end
18
19 def all() do
20 PackageReport.all()
21 1 |> Repo.all()
22 end
23
24 def get(id) do
25 PackageReport.get(id)
26 27 |> Repo.one()
27 end
28
29 def accept(report_id) do
30 4 report =
31 PackageReport.get(report_id)
32 |> Repo.one()
33 |> PackageReport.change_state(%{"state" => "accepted"})
34 |> Repo.update!()
35
36 4 users =
37 4 Enum.map(Owners.all(report.package, [:user]), & &1.user) ++
38 4 [report.author] ++
39 Users.get_by_role("moderator")
40
41 4 Enum.each(users, &email_state_change(report, &1))
42 end
43
44 def reject(report_id) do
45 1 report =
46 PackageReport.get(report_id)
47 |> Repo.one()
48 |> PackageReport.change_state(%{"state" => "rejected"})
49 |> Repo.update!()
50
51 1 Enum.each(
52 1 [report.author] ++ Users.get_by_role("moderator"),
53 2 &email_state_change(report, &1)
54 )
55 end
56
57 def solve(report_id) do
58 2 report =
59 PackageReport.get(report_id)
60 |> Repo.one()
61 |> PackageReport.change_state(%{"state" => "solved"})
62 |> Repo.update!()
63
64 2 Enum.each(report.releases, &mark_release/1)
65
66 2 users =
67 2 Enum.map(Owners.all(report.package, [:user]), & &1.user) ++
68 Users.get_by_role("moderator")
69
70 2 Enum.each(users, &email_state_change(report, &1))
71 end
72
73 def unresolve(report_id) do
74 1 report =
75 PackageReport.get(report_id)
76 |> Repo.one()
77 |> PackageReport.change_state(%{"state" => "unresolved"})
78 |> Repo.update!()
79
80 1 Enum.each(report.releases, &PackageReports.mark_release/1)
81
82 1 users =
83 1 Enum.map(Owners.all(report.package, [:user]), & &1.user) ++ Users.get_by_role("moderator")
84
85 1 Enum.each(users, &email_state_change(report, &1))
86 end
87
88 def new_comment(report, author, params) do
89 1 comment = Repo.insert!(PackageReportComment.build(report, author, params))
90
91 1 users =
92 1 Enum.map(Owners.all(report.package, [:user]), & &1.user) ++
93 [author] ++
94 Users.get_by_role("moderator")
95
96 1 Enum.each(users, &email_new_comment(comment, report, &1))
97
98 1 comment
99 end
100
101 def all_comments(report_id) do
102 PackageReportComment.all_for_report(report_id)
103 15 |> Repo.all()
104 end
105
106 def mark_release(release) do
107 Release.reported_retire(release)
108 4 |> Repo.update!()
109 end
110
111 defp email_new_report(package_report, user) do
112 user
113 |> Hexpm.Repo.preload([:emails])
114 |> Emails.report_submitted(
115 6 package_report.author.username,
116 6 package_report.package.name,
117 6 package_report.id,
118 6 package_report.inserted_at
119 )
120 6 |> Mailer.deliver_later!()
121 end
122
123 defp email_new_comment(comment, report, user) do
124 user
125 |> Hexpm.Repo.preload([:emails])
126 |> Emails.report_commented(
127 3 comment.author.username,
128 3 report.id,
129 3 comment.inserted_at
130 )
131 3 |> Mailer.deliver_later!()
132 end
133
134 defp email_state_change(package_report, user) do
135 user
136 |> Hexpm.Repo.preload([:emails])
137 |> Emails.report_state_changed(
138 20 package_report.id,
139 20 package_report.state,
140 20 package_report.updated_at
141 )
142 20 |> Mailer.deliver_later!()
143 end
144 end

lib/hexpm/repository/packages.ex

90.9
44
1177
4
Line Hits Source
0 defmodule Hexpm.Repository.Packages do
1 use Hexpm.Context
2
3 def count() do
4 2 Repo.one!(Package.count())
5 end
6
7 def count(repositories, filter) do
8 17 Repo.one!(Package.count(repositories, filter))
9 end
10
11 def get(repository, name) when is_binary(repository) do
12 0 repository = Repositories.get(repository)
13 0 repository && get(repository, name)
14 end
15
16 def get(repositories, name) when is_list(repositories) do
17 Repo.get_by(assoc(repositories, :packages), name: name)
18 4 |> Repo.preload(:repository)
19 end
20
21 def get(repository, name) do
22 178 package = Repo.get_by(assoc(repository, :packages), name: name)
23 178 package && %{package | repository: repository}
24 end
25
26 def owner_with_access?(package, user, level \\ "maintainer") do
27 79 repository = package.repository
28 79 role = PackageOwner.level_to_organization_role(level)
29
30 55 Repo.one!(Package.package_owner(package, user, level)) or
31 79 Repo.one!(Package.organization_owner(package, user, level)) or
32 21 (repository.id != 1 and Organizations.access?(repository.organization, user, role))
33 end
34
35 def preload(package) do
36 64 package = Repo.preload(package, [:downloads, :releases])
37 64 update_in(package.releases, &Release.sort/1)
38 end
39
40 def attach_versions(packages) do
41 14 versions = Releases.package_versions(packages)
42
43 14 Enum.map(packages, fn package ->
44 20 version =
45 20 Release.latest_version(versions[package.id], only_stable: true, unstable_fallback: true)
46
47 20 %{package | latest_version: version}
48 end)
49 end
50
51 def search(repositories, page, packages_per_page, query, sort, fields) do
52 Package.all(repositories, page, packages_per_page, query, sort, fields)
53 |> Repo.all()
54 17 |> attach_repositories(repositories)
55 end
56
57 def search_with_versions(repositories, page, packages_per_page, query, sort) do
58 Package.all(repositories, page, packages_per_page, query, sort, nil)
59 10 |> Ecto.Query.preload(
60 releases: ^from(r in Release, select: struct(r, [:id, :version, :updated_at, :has_docs]))
61 )
62 |> Repo.all()
63 20 |> Enum.map(fn package -> update_in(package.releases, &Release.sort/1) end)
64 10 |> attach_repositories(repositories)
65 end
66
67 defp attach_repositories(packages, repositories) do
68 27 repositories = Map.new(repositories, &{&1.id, &1})
69
70 27 Enum.map(packages, fn package ->
71 31 repository = Map.fetch!(repositories, package.repository_id)
72 31 %{package | repository: repository}
73 end)
74 end
75
76 def recent(repository, count) do
77 2 Repo.all(Package.recent(repository, count))
78 end
79
80 def package_downloads(package) do
81 PackageDownload.package(package)
82 |> Repo.all()
83 9 |> Enum.into(%{})
84 end
85
86 def packages_downloads_with_all_views(packages) do
87 PackageDownload.packages_and_all_download_views(packages)
88 |> Repo.all()
89 12 |> Enum.reduce(%{}, fn {id, view, dls}, acc ->
90 0 Map.update(acc, id, %{view => dls}, &Map.put(&1, view, dls))
91 end)
92 end
93
94 def packages_downloads(packages, view) do
95 PackageDownload.packages(packages, view)
96 |> Repo.all()
97 0 |> Enum.into(%{})
98 end
99
100 def top_downloads(repository, view, count) do
101 2 top = Repo.all(PackageDownload.top(repository, view, count))
102 2 packages = top |> Enum.map(fn {package, _downloads} -> package end) |> attach_versions()
103
104 2 Enum.zip_with(packages, top, fn package, {_package, downloads} ->
105 {package, downloads}
106 end)
107 end
108
109 def total_downloads() do
110 PackageDownload.total()
111 |> Repo.all()
112 2 |> Enum.into(%{})
113 end
114
115 1 def accessible_user_owned_packages(nil, _) do
116 []
117 end
118
119 def accessible_user_owned_packages(user, for_user) do
120 9 repositories = Enum.map(Users.all_organizations(for_user), & &1.repository)
121 9 repository_ids = Enum.map(repositories, & &1.id)
122
123 # Atoms sort before strings
124 9 sorter = fn repo -> if(repo.id == 1, do: :first, else: repo.name) end
125
126 9 user.owned_packages
127 14 |> Enum.filter(&(&1.repository_id in repository_ids))
128 9 |> Enum.sort_by(&[sorter.(&1.repository), &1.name])
129 end
130
131 def downloads_for_last_n_days(package_id, num_of_days) do
132 Package.downloads_for_last_n_days(package_id, num_of_days)
133 5 |> Repo.all()
134 end
135 end

lib/hexpm/repository/registry_builder.ex

92.1
164
7955
13
Line Hits Source
0 defmodule Hexpm.Repository.RegistryBuilder do
1 import Ecto.Query, only: [from: 2]
2 require Hexpm.Repo
3 require Logger
4 alias Hexpm.Repository.{Package, Release, Repository, Requirement}
5 alias Hexpm.Repo
6
7 def full(repository) do
8 7 locked_build(fn -> build_full(repository) end, 300_000)
9 end
10
11 # NOTE: Does not rebuild package indexes, use full/1 instead
12 def repository(repository) do
13 41 locked_build(fn -> build_partial(repository) end, 30_000)
14 end
15
16 def package(package) do
17 40 build_package(package)
18 end
19
20 def package_delete(package) do
21 7 delete_package(package)
22 end
23
24 defp locked_build(fun, timeout) do
25 48 start_time = System.monotonic_time(:millisecond)
26 48 lock(fun, start_time, timeout)
27 end
28
29 defp lock(fun, start_time, timeout) do
30 48 now = System.monotonic_time(:millisecond)
31
32 48 if now > start_time + timeout do
33 0 raise "lock timeout"
34 end
35
36 48 {:ok, ran?} =
37 Repo.transaction(
38 48 fn -> run_with_lock(fun, now - start_time) end,
39 timeout: timeout
40 )
41
42 48 unless ran? do
43 0 Process.sleep(1000)
44 0 lock(fun, start_time, timeout)
45 end
46 end
47
48 48 if Mix.env() == :test do
49 defp run_with_lock(fun, time) do
50 48 if Repo.try_advisory_lock?(:registry) do
51 48 try do
52 48 Logger.warn("REGISTRY_BUILDER aquired_lock (#{time}ms)")
53 48 fun.()
54 true
55 after
56 48 Repo.advisory_unlock(:registry)
57 end
58 else
59 0 Logger.warn("REGISTRY_BUILDER failed_aquire_lock (#{time}ms)")
60 false
61 end
62 end
63 else
64 defp run_with_lock(fun, time) do
65 if Repo.try_advisory_xact_lock?(:registry) do
66 Logger.warn("REGISTRY_BUILDER aquired_lock (#{time}ms)")
67 fun.()
68 true
69 else
70 Logger.warn("REGISTRY_BUILDER failed_aquire_lock (#{time}ms)")
71 false
72 end
73 end
74 end
75
76 defp build_full(repository) do
77 7 log(:all, fn ->
78 7 {packages, releases} = tuples(repository, nil)
79
80 7 new = build_all(repository, packages, releases)
81 7 upload_files(repository, new)
82
83 7 {_, _, packages} = new
84
85 7 new_keys =
86 14 Enum.map(packages, &repository_store_key(repository, "packages/#{elem(&1, 0)}"))
87 |> Enum.sort()
88
89 7 old_keys =
90 Hexpm.Store.list(:repo_bucket, repository_store_key(repository, "packages/"))
91 |> Enum.sort()
92
93 7 Hexpm.Store.delete_many(:repo_bucket, old_keys -- new_keys)
94
95 7 Hexpm.CDN.purge_key(:fastly_hexrepo, [
96 "registry",
97 repository_cdn_key(repository, "registry")
98 ])
99 end)
100 end
101
102 defp build_partial(repository) do
103 41 log(:repository, fn ->
104 41 {packages, releases} = tuples(repository, nil)
105 41 release_map = Map.new(releases)
106
107 41 names = build_names(repository, packages)
108 41 versions = build_versions(repository, packages, release_map)
109 41 upload_files(repository, {names, versions, []})
110
111 41 Hexpm.CDN.purge_key(:fastly_hexrepo, [
112 "registry-index",
113 repository_cdn_key(repository, "registry-index")
114 ])
115 end)
116 end
117
118 defp build_package(package) do
119 40 log(:package_build, fn ->
120 40 repository = package.repository
121
122 40 {packages, releases} = tuples(repository, package)
123 40 release_map = Map.new(releases)
124 40 packages = build_packages(repository, packages, release_map)
125
126 40 upload_files(repository, {nil, nil, packages})
127
128 40 Hexpm.CDN.purge_key(:fastly_hexrepo, [
129 40 "registry-package-#{package.name}",
130 40 repository_cdn_key(repository, "registry-package", package.name)
131 ])
132 end)
133 end
134
135 defp delete_package(package) do
136 7 log(:package_delete, fn ->
137 7 repository = package.repository
138
139 7 Hexpm.Store.delete(
140 :repo_bucket,
141 7 repository_store_key(repository, "packages/#{package.name}")
142 )
143
144 7 Hexpm.CDN.purge_key(:fastly_hexrepo, [
145 7 "registry-package-#{package.name}",
146 7 repository_cdn_key(repository, "registry-package", package.name)
147 ])
148 end)
149 end
150
151 defp tuples(repository, package) do
152 88 requirements = requirements(repository, package)
153 88 releases = releases(repository, package)
154 88 packages = packages(repository, package)
155 88 package_tuples = package_tuples(packages, releases)
156 88 release_tuples = release_tuples(packages, releases, requirements)
157
158 {package_tuples, release_tuples}
159 end
160
161 defp log(type, fun) do
162 95 try do
163 95 {time, _} = :timer.tc(fun)
164 95 Logger.warn("REGISTRY_BUILDER completed #{type} (#{div(time, 1000)}ms)")
165 catch
166 exception ->
167 0 Logger.error("REGISTRY_BUILDER failed #{type}")
168 0 reraise exception, __STACKTRACE__
169 end
170 end
171
172 defp sign_protobuf(contents) do
173 150 private_key = Application.fetch_env!(:hexpm, :private_key)
174 150 :hex_registry.sign_protobuf(contents, private_key)
175 end
176
177 defp build_all(repository, packages, releases) do
178 7 release_map = Map.new(releases)
179
180 7 {
181 build_names(repository, packages),
182 build_versions(repository, packages, release_map),
183 build_packages(repository, packages, release_map)
184 }
185 end
186
187 defp build_names(repository, packages) do
188 48 packages =
189 Enum.map(packages, fn {name, {updated_at, _versions}} ->
190 # Currently using Package.updated_at, would be more accurate to use
191 # a timestamp that is only updated when the registry is updated by:
192 # publish, revert, or retire
193 67 {seconds, nanos} = to_unix_nano(updated_at)
194
195 67 %{
196 name: name,
197 updated_at: %{seconds: seconds, nanos: nanos}
198 }
199 end)
200
201 48 %{packages: packages, repository: repository.name}
202 |> :hex_registry.encode_names()
203 |> sign_protobuf()
204 48 |> :zlib.gzip()
205 end
206
207 defp build_versions(repository, packages, release_map) do
208 48 packages =
209 Enum.map(packages, fn {name, {_updated_at, [versions]}} ->
210 67 %{
211 name: name,
212 versions: versions,
213 retired: build_retired_indexes(name, versions, release_map)
214 }
215 end)
216
217 48 %{packages: packages, repository: repository.name}
218 |> :hex_registry.encode_versions()
219 |> sign_protobuf()
220 48 |> :zlib.gzip()
221 end
222
223 defp build_retired_indexes(name, versions, release_map) do
224 versions
225 |> Enum.with_index()
226 67 |> Enum.flat_map(fn {version, ix} ->
227 79 [_deps, _inner_checksum, _outer_checksum, _tools, retirement] = release_map[{name, version}]
228 79 if retirement, do: [ix], else: []
229 end)
230 end
231
232 defp build_packages(repository, packages, release_map) do
233 47 Enum.map(packages, fn {name, {_updated_at, [versions]}} ->
234 54 contents = build_package(repository, name, versions, release_map)
235 {name, contents}
236 end)
237 end
238
239 defp build_package(repository, name, versions, release_map) do
240 54 releases =
241 Enum.map(versions, fn version ->
242 71 [deps, inner_checksum, outer_checksum, _tools, retirement] = release_map[{name, version}]
243
244 71 deps =
245 Enum.map(deps, fn [repo, dep, req, opt, app] ->
246 17 map = %{package: dep, requirement: req || ">= 0.0.0"}
247 17 map = if opt, do: Map.put(map, :optional, true), else: map
248 17 map = if app != dep, do: Map.put(map, :app, app), else: map
249 17 map = if repository.name != repo, do: Map.put(map, :repository, repo), else: map
250 17 map
251 end)
252
253 71 release = %{
254 version: version,
255 inner_checksum: inner_checksum,
256 outer_checksum: outer_checksum,
257 dependencies: deps
258 }
259
260 71 if retirement do
261 6 retire = %{reason: retirement_reason(retirement.reason)}
262
263 6 retire =
264 6 if retirement.message, do: Map.put(retire, :message, retirement.message), else: retire
265
266 6 Map.put(release, :retired, retire)
267 else
268 65 release
269 end
270 end)
271
272 %{
273 name: name,
274 54 repository: repository.name,
275 releases: releases
276 }
277 |> :hex_registry.encode_package()
278 |> sign_protobuf()
279 54 |> :zlib.gzip()
280 end
281
282 0 defp retirement_reason("other"), do: :RETIRED_OTHER
283 0 defp retirement_reason("invalid"), do: :RETIRED_INVALID
284 6 defp retirement_reason("security"), do: :RETIRED_SECURITY
285 0 defp retirement_reason("deprecated"), do: :RETIRED_DEPRECATED
286 0 defp retirement_reason("renamed"), do: :RETIRED_RENAMED
287
288 defp upload_files(repository, objects) do
289 88 upload_objects(objects(objects, repository))
290 end
291
292 defp upload_objects(objects) do
293 Task.async_stream(
294 objects,
295 fn {key, data, opts} ->
296 150 Hexpm.Store.put(:repo_bucket, key, data, opts)
297 end,
298 max_concurrency: 10,
299 timeout: 60_000
300 )
301 88 |> Stream.run()
302 end
303
304 0 defp objects(nil, _repository) do
305 []
306 end
307
308 defp objects({nil, nil, packages}, repository) do
309 40 package_objects(packages, repository)
310 end
311
312 defp objects({names, versions, packages}, repository) do
313 48 index_objects(names, versions, repository) ++ package_objects(packages, repository)
314 end
315
316 defp index_objects(names, versions, repository) do
317 48 surrogate_key =
318 Enum.join(
319 [
320 repository_cdn_key(repository, "registry"),
321 repository_cdn_key(repository, "registry-index")
322 ],
323 " "
324 )
325
326 48 meta = [
327 {"surrogate-key", surrogate_key},
328 {"surrogate-control", "public, max-age=604800"}
329 ]
330
331 48 opts = [cache_control: cache_control(repository), meta: meta]
332 48 index_opts = Keyword.put(opts, :meta, meta)
333
334 48 names_object = {repository_store_key(repository, "names"), names, index_opts}
335 48 versions_object = {repository_store_key(repository, "versions"), versions, index_opts}
336
337 [names_object, versions_object]
338 end
339
340 defp package_objects(packages, repository) do
341 88 Enum.map(packages, fn {name, contents} ->
342 54 surrogate_key =
343 Enum.join(
344 [
345 repository_cdn_key(repository, "registry"),
346 repository_cdn_key(repository, "registry-package", name)
347 ],
348 " "
349 )
350
351 54 meta = [
352 {"surrogate-key", surrogate_key},
353 {"surrogate-control", "public, max-age=604800"}
354 ]
355
356 54 opts = [cache_control: cache_control(repository), meta: meta]
357 54 {repository_store_key(repository, "packages/#{name}"), contents, opts}
358 end)
359 end
360
361 77 defp cache_control(%Repository{id: 1}), do: "public, max-age=3600"
362 25 defp cache_control(%Repository{}), do: "private, max-age=3600"
363
364 defp package_tuples(packages, releases) do
365 Enum.reduce(releases, %{}, fn map, acc ->
366 131 case Map.fetch(packages, map.package_id) do
367 {:ok, {package, updated_at}} ->
368 131 Map.update(acc, package, {updated_at, [map.version]}, fn {^updated_at, versions} ->
369 24 {updated_at, [map.version | versions]}
370 end)
371
372 :error ->
373 0 acc
374 end
375 end)
376 88 |> sort_package_tuples()
377 end
378
379 defp sort_package_tuples(tuples) do
380 Enum.map(tuples, fn {name, {updated_at, versions}} ->
381 107 versions =
382 versions
383 24 |> Enum.sort(&(Version.compare(&1, &2) == :lt))
384 131 |> Enum.map(&to_string/1)
385
386 {name, {updated_at, [versions]}}
387 end)
388 88 |> Enum.sort()
389 end
390
391 defp release_tuples(packages, releases, requirements) do
392 88 Enum.flat_map(releases, fn map ->
393 131 case Map.fetch(packages, map.package_id) do
394 {:ok, {package, _updated_at}} ->
395 131 key = {package, to_string(map.version)}
396 131 deps = deps_list(requirements[map.release_id] || [])
397 131 value = [deps, map.inner_checksum, map.outer_checksum, map.build_tools, map.retirement]
398 [{key, value}]
399
400 0 :error ->
401 []
402 end
403 end)
404 end
405
406 defp deps_list(requirements) do
407 28 Enum.map(requirements, fn map ->
408 28 [map.repository, map.package, map.requirement, map.optional, map.app]
409 end)
410 131 |> Enum.sort()
411 end
412
413 defp packages(repository, nil) do
414 from(
415 p in Package,
416 48 where: p.repository_id == ^repository.id,
417 select: {p.id, {p.name, p.updated_at}}
418 )
419 |> Repo.all()
420 48 |> Map.new()
421 end
422
423 defp packages(_repository, package) do
424 40 %{package.id => {package.name, package.updated_at}}
425 end
426
427 defp releases(repository, package) do
428 from(
429 r in Release,
430 join: p in assoc(r, :package),
431 select: %{
432 release_id: r.id,
433 version: r.version,
434 package_id: r.package_id,
435 inner_checksum: r.inner_checksum,
436 outer_checksum: r.outer_checksum,
437 build_tools: fragment("?->'build_tools'", r.meta),
438 retirement: r.retirement
439 }
440 )
441 |> releases_where(repository, package)
442 88 |> Hexpm.Repo.all()
443 end
444
445 defp releases_where(query, repository, nil) do
446 48 from(
447 [r, p] in query,
448 48 where: p.repository_id == ^repository.id
449 )
450 end
451
452 defp releases_where(query, _repository, package) do
453 40 from(
454 [r, p] in query,
455 40 where: p.id == ^package.id
456 )
457 end
458
459 defp requirements(repository, package) do
460 88 reqs =
461 from(
462 req in Requirement,
463 join: rel in assoc(req, :release),
464 join: parent in assoc(rel, :package),
465 join: dep in assoc(req, :dependency),
466 join: dep_repo in assoc(dep, :repository),
467 select: %{
468 release_id: req.release_id,
469 repository: dep_repo.name,
470 package: dep.name,
471 app: req.app,
472 requirement: req.requirement,
473 optional: req.optional
474 }
475 )
476 |> requirements_where(repository, package)
477 |> Repo.all()
478
479 88 Enum.reduce(reqs, %{}, fn map, acc ->
480 28 {release_id, map} = Map.pop(map, :release_id)
481 28 Map.update(acc, release_id, [map], &[map | &1])
482 end)
483 end
484
485 defp requirements_where(query, repository, nil) do
486 48 from(
487 [req, rel, parent] in query,
488 48 where: parent.repository_id == ^repository.id
489 )
490 end
491
492 defp requirements_where(query, _repository, package) do
493 40 from(
494 [req, rel, parent] in query,
495 40 where: parent.id == ^package.id
496 )
497 end
498
499 defp repository_cdn_key(%Repository{id: 1}, key) do
500 149 key
501 end
502
503 defp repository_cdn_key(%Repository{name: name}, key) do
504 49 "#{key}/#{name}"
505 end
506
507 defp repository_cdn_key(%Repository{id: 1}, prefix, suffix) do
508 74 "#{prefix}/#{suffix}"
509 end
510
511 defp repository_cdn_key(%Repository{name: name}, prefix, suffix) do
512 27 "#{prefix}/#{name}/#{suffix}"
513 end
514
515 defp repository_store_key(%Repository{id: 1}, key) do
516 137 key
517 end
518
519 defp repository_store_key(%Repository{name: name}, key) do
520 41 "repos/#{name}/#{key}"
521 end
522
523 defp to_unix_nano(datetime) do
524 67 unix = DateTime.to_unix(datetime, :nanosecond)
525 {div(unix, 1_000_000_000), rem(unix, 1_000_000_000)}
526 end
527 end

lib/hexpm/repository/release.ex

95.5
67
4253
3
Line Hits Source
0 defmodule Hexpm.Repository.Release do
1 use Hexpm.Schema
2
3 @derive {HexpmWeb.Stale, assocs: [:requirements, :downloads]}
4 @one_hour 60 * 60
5 @one_day @one_hour * 24
6
7 1986 schema "releases" do
8 field :version, Hexpm.Version
9 field :inner_checksum, :binary
10 field :outer_checksum, :binary
11 field :has_docs, :boolean, default: false
12 timestamps()
13
14 belongs_to :package, Package
15 belongs_to(:publisher, User, on_replace: :nilify)
16 has_many :requirements, Requirement, on_replace: :delete
17 has_many :daily_downloads, Download
18 has_many :package_report_releases, PackageReportRelease
19 has_many :package_reports, through: [:package_report_releases, :package_report]
20 has_one :downloads, ReleaseDownload
21
22 embeds_one :meta, ReleaseMetadata, on_replace: :delete
23 embeds_one :retirement, ReleaseRetirement, on_replace: :delete
24 end
25
26 defp changeset(
27 release,
28 :create,
29 params,
30 package,
31 publisher,
32 inner_checksum,
33 outer_checksum,
34 replace?
35 ) do
36 changeset(
37 release,
38 :update,
39 params,
40 package,
41 publisher,
42 inner_checksum,
43 outer_checksum,
44 replace?
45 )
46 76 |> unique_constraint(
47 :version,
48 name: "releases_package_id_version_key",
49 message: "has already been published"
50 )
51 end
52
53 defp changeset(
54 release,
55 :update,
56 params,
57 package,
58 publisher,
59 inner_checksum,
60 outer_checksum,
61 replace?
62 ) do
63 cast(release, params, ~w(version)a)
64 |> cast_embed(:meta, required: true)
65 |> validate_version(:version)
66 |> validate_editable(:update, false, replace?)
67 |> put_change(:inner_checksum, inner_checksum)
68 |> put_change(:outer_checksum, outer_checksum)
69 |> put_assoc(:publisher, publisher)
70 90 |> Requirement.build_all(package)
71 end
72
73 def build(package, publisher, params, inner_checksum, outer_checksum, replace? \\ true) do
74 build_assoc(package, :releases)
75 76 |> changeset(:create, params, package, publisher, inner_checksum, outer_checksum, replace?)
76 end
77
78 def update(release, publisher, params, inner_checksum, outer_checksum, replace? \\ true) do
79 14 changeset(
80 release,
81 :update,
82 params,
83 14 release.package,
84 publisher,
85 inner_checksum,
86 outer_checksum,
87 replace?
88 )
89 end
90
91 def delete(release, opts \\ []) do
92 12 force? = Keyword.get(opts, :force, false)
93
94 change(release)
95 12 |> validate_editable(:delete, force?, true)
96 end
97
98 def retire(release, params) do
99 3 cast_embed(
100 cast(release, params, []),
101 :retirement,
102 required: true,
103 3 with: &ReleaseRetirement.changeset(&1, &2, public: true)
104 )
105 end
106
107 def reported_retire(release) do
108 change(
109 release,
110 %{
111 retirement: %{
112 reason: "report",
113 message: "security vulnerability reported"
114 }
115 }
116 )
117 4 |> cast_embed(
118 :retirement,
119 required: true,
120 0 with: &ReleaseRetirement.changeset(&1, &2, public: false)
121 )
122 end
123
124 def unretire(release) do
125 change(release)
126 3 |> put_embed(:retirement, nil)
127 end
128
129 defp validate_editable(changeset, _action, true = _force?, _replace?) do
130 0 changeset
131 end
132
133 defp validate_editable(changeset, action, _force?, replace?) do
134 102 cond do
135 102 is_nil(changeset.data.inserted_at) ->
136 76 changeset
137
138 26 not editable?(changeset.data) ->
139 4 add_error(changeset, :inserted_at, editable_error_message(action))
140
141 22 replace? not in [true, "true"] ->
142 2 message = "must include the --replace flag to update an existing release"
143 2 add_error(changeset, :inserted_at, message)
144
145 20 true ->
146 20 changeset
147 end
148 end
149
150 3 defp editable_error_message(:update) do
151 "can only modify a release up to one hour after publication"
152 end
153
154 1 defp editable_error_message(:delete),
155 do: "can only delete a release up to one hour after publication"
156
157 defp editable?(release) do
158 26 release.package.repository.id != 1 or
159 26 within_seconds?(release.inserted_at, @one_hour) or
160 5 within_seconds?(release.package.inserted_at, @one_day)
161 end
162
163 defp within_seconds?(datetime, within_seconds) do
164 26 at =
165 datetime
166 |> NaiveDateTime.to_erl()
167 |> erl_to_seconds()
168
169 26 now = erl_to_seconds(:calendar.universal_time())
170 26 now - at <= within_seconds
171 end
172
173 52 defp erl_to_seconds(datetime), do: :calendar.datetime_to_gregorian_seconds(datetime)
174
175 def package_versions(packages) do
176 14 package_ids = Enum.map(packages, & &1.id)
177
178 14 from(
179 r in Release,
180 where: r.package_id in ^package_ids,
181 group_by: r.package_id,
182 select: {r.package_id, fragment("array_agg(?)", r.version)}
183 )
184 end
185
186 7 def latest_version(nil, _opts), do: nil
187
188 def latest_version(releases, opts) do
189 76 only_stable? = Keyword.fetch!(opts, :only_stable)
190 76 unstable_fallback? = Keyword.get(opts, :unstable_fallback, false)
191 76 with_docs? = Keyword.get(opts, :with_docs)
192
193 76 with_docs_releases =
194 if with_docs? do
195 10 Enum.filter(releases, & &1.has_docs)
196 else
197 66 releases
198 end
199
200 76 stable_releases =
201 if only_stable? do
202 52 Enum.filter(with_docs_releases, &(to_version(&1).pre == []))
203 else
204 24 with_docs_releases
205 end
206
207 76 if stable_releases == [] and unstable_fallback? do
208 2 latest(releases)
209 else
210 74 latest(stable_releases)
211 end
212 end
213
214 0 defp latest([]), do: nil
215
216 defp latest(releases) do
217 76 Enum.reduce(releases, fn release, latest ->
218 24 if compare(release, latest) == :lt do
219 16 latest
220 else
221 8 release
222 end
223 end)
224 end
225
226 defp compare(release1, release2) do
227 24 Version.compare(to_version(release1), to_version(release2))
228 end
229
230 95 defp to_version(%Release{version: version}), do: to_version(version)
231 95 defp to_version(%Version{} = version), do: version
232 38 defp to_version(version) when is_binary(version), do: Version.parse!(version)
233
234 def all(package) do
235 15 assoc(package, :releases)
236 end
237
238 def sort(releases) do
239 97 Enum.sort(releases, &(Version.compare(&1.version, &2.version) == :gt))
240 end
241
242 def requirements(release) do
243 28 from(
244 req in assoc(release, :requirements),
245 join: package in assoc(req, :dependency),
246 join: repo in assoc(package, :repository),
247 order_by: [repo.name, package.name],
248 select: %{req | name: package.name, repository: repo.name}
249 )
250 end
251
252 def count() do
253 2 from(r in Release, select: count(r.id))
254 end
255
256 def recent(repository, count) do
257 2 from(
258 r in Hexpm.Repository.Release,
259 join: p in assoc(r, :package),
260 2 where: p.repository_id == ^repository.id,
261 order_by: [desc: r.inserted_at],
262 limit: ^count,
263 select: {p.name, r.version, r.inserted_at, p.meta}
264 )
265 end
266
267 def downloads_for_last_n_days(release_id, num_of_days) do
268 4 date_start = Date.add(Date.utc_today(), -1 * num_of_days)
269 4 from(d in downloads_by_period(release_id, :day), where: d.day >= ^date_start)
270 end
271
272 def downloads_by_period(release_id, filter) do
273 from(d in Download, where: d.release_id == ^release_id)
274 12 |> Download.query_filter(filter)
275 end
276 end
277
278 defimpl Phoenix.Param, for: Hexpm.Repository.Release do
279 def to_param(release) do
280 132 to_string(release.version)
281 end
282 end

lib/hexpm/repository/release_download.ex

100
2
28
0
Line Hits Source
0 defmodule Hexpm.Repository.ReleaseDownload do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @primary_key false
5
6 19 schema "release_downloads" do
7 belongs_to(:release, Release, references: :id)
8 field :downloads, :integer
9 end
10
11 def release(release) do
12 9 from(rd in ReleaseDownload, where: rd.release_id == ^release.id)
13 end
14 end

lib/hexpm/repository/release_metadata.ex

100
2
1940
0
Line Hits Source
0 defmodule Hexpm.Repository.ReleaseMetadata do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4
5 1853 embedded_schema do
6 field :app, :string
7 field :build_tools, {:array, :string}
8 field :elixir, :string
9 field :files, {:array, :string}, virtual: true
10 end
11
12 def changeset(meta, params) do
13 cast(meta, params, ~w(app build_tools elixir files)a)
14 |> validate_required(~w(app build_tools files)a)
15 |> validate_list_required(:build_tools)
16 |> validate_list_required(:files, message: "package can't be empty")
17 |> update_change(:build_tools, &Enum.uniq/1)
18 87 |> validate_requirement(:elixir)
19 end
20 end

lib/hexpm/repository/release_retirement.ex

50
10
150
5
Line Hits Source
0 defmodule Hexpm.Repository.ReleaseRetirement do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4
5 136 embedded_schema do
6 field :reason, :string
7 field :message, :string
8 end
9
10 @public_reasons ~w(other invalid security deprecated renamed)
11 @private_reasons @public_reasons ++ ~w(report)
12
13 def changeset(meta, params, opts) do
14 cast(meta, params, ~w(reason message)a)
15 |> validate_required(~w(reason)a)
16 |> validate_length(:message, min: 3, max: 140)
17 3 |> validate_reason(Keyword.fetch!(opts, :public))
18 end
19
20 defp validate_reason(changeset, true = _public?),
21 3 do: validate_inclusion(changeset, :reason, @public_reasons)
22
23 defp validate_reason(changeset, false = _public?),
24 0 do: validate_inclusion(changeset, :reason, @private_reasons)
25
26 4 def reason_text("other"), do: nil
27 0 def reason_text("invalid"), do: "Release invalid"
28 4 def reason_text("security"), do: "Security issue"
29 0 def reason_text("deprecated"), do: "Deprecated"
30 0 def reason_text("renamed"), do: "Renamed"
31 0 def reason_text("report"), do: "Reported vulnerability"
32 end

lib/hexpm/repository/releases.ex

95.1
102
1780
5
Line Hits Source
0 defmodule Hexpm.Repository.Releases do
1 use Hexpm.Context
2
3 @publish_timeout 60_000
4
5 def all(package) do
6 Release.all(package)
7 |> Repo.all()
8 11 |> Release.sort()
9 end
10
11 def recent(repository, count) do
12 2 Repo.all(Release.recent(repository, count))
13 end
14
15 def count() do
16 2 Repo.one!(Release.count())
17 end
18
19 def get(package, version) do
20 62 release = Repo.get_by(assoc(package, :releases), version: version)
21 62 release && %{release | package: package}
22 end
23
24 def get(repository, package, version) when is_binary(package) do
25 0 package = Packages.get(repository, package)
26 0 package && get(package, version)
27 end
28
29 def package_versions(packages) do
30 Release.package_versions(packages)
31 |> Repo.all()
32 14 |> Enum.into(%{})
33 end
34
35 def preload(release, keys) do
36 28 preload = Enum.map(keys, &preload_field(release, &1))
37 28 Repo.preload(release, preload)
38 end
39
40 def publish(repository, package, user, body, meta, inner_checksum, outer_checksum,
41 audit: audit_data,
42 replace: replace?
43 ) do
44 Multi.new()
45 40 |> Multi.run(:repository, fn _, _ -> {:ok, repository} end)
46 40 |> Multi.run(:reserved_packages, fn _, _ -> {:ok, reserved_packages(repository, meta)} end)
47 |> create_package(repository, package, user, meta)
48 |> create_release(package, user, inner_checksum, outer_checksum, meta, replace?)
49 |> audit_publish(audit_data)
50 |> refresh_package_dependants()
51 |> Repo.transaction(timeout: @publish_timeout)
52 42 |> publish_result(user, body)
53 end
54
55 def publish_docs(package, release, body, audit: audit_data) do
56 7 Assets.push_docs(release, body)
57
58 7 now = DateTime.utc_now()
59 7 release_changeset = Ecto.Changeset.change(release, has_docs: true)
60 7 package_changeset = Ecto.Changeset.change(release.package, docs_updated_at: now)
61
62 7 {:ok, _} =
63 Multi.new()
64 |> Multi.update(:release, release_changeset)
65 |> Multi.update(:package, package_changeset)
66 |> audit(audit_data, "docs.publish", {package, release})
67 |> Repo.transaction()
68 end
69
70 def revert(package, release, audit: audit_data) do
71 Multi.new()
72 |> Multi.delete(:release, Release.delete(release))
73 |> audit_revert(audit_data, package, release)
74 |> Multi.run(:release_count, &release_count/2)
75 |> Multi.run(:package, &maybe_delete_package/2)
76 |> refresh_package_dependants()
77 |> Repo.transaction(timeout: @publish_timeout)
78 11 |> revert_result()
79 end
80
81 def revert_docs(release, audit: audit_data) do
82 2 now = DateTime.utc_now()
83 2 release_changeset = Ecto.Changeset.change(release, has_docs: false)
84 2 package_changeset = Ecto.Changeset.change(release.package, docs_updated_at: now)
85
86 2 {:ok, _} =
87 Multi.new()
88 |> Multi.update(:release, release_changeset)
89 |> Multi.update(:package, package_changeset)
90 2 |> audit(audit_data, "docs.revert", {release.package, release})
91 |> Repo.transaction()
92
93 2 Assets.revert_docs(release)
94 end
95
96 def retire(package, release, params, audit: audit_data) do
97 3 params = %{"retirement" => params}
98
99 Multi.new()
100 3 |> Multi.run(:repository, fn _, _ -> {:ok, package.repository} end)
101 3 |> Multi.run(:package, fn _, _ -> {:ok, package} end)
102 |> Multi.update(:release, Release.retire(release, params))
103 |> audit_retire(audit_data, package)
104 |> Repo.transaction()
105 3 |> retire_result()
106 end
107
108 def unretire(package, release, audit: audit_data) do
109 Multi.new()
110 3 |> Multi.run(:repository, fn _, _ -> {:ok, package.repository} end)
111 3 |> Multi.run(:package, fn _, _ -> {:ok, package} end)
112 |> Multi.update(:release, Release.unretire(release))
113 |> audit_unretire(audit_data, package)
114 |> Repo.transaction()
115 3 |> retire_result()
116 end
117
118 def downloads_by_period(package, filter) do
119 8 Release.downloads_by_period(package, filter || :all)
120 8 |> Repo.all()
121 end
122
123 def downloads_for_last_n_days(release_id, num_of_days) do
124 Release.downloads_for_last_n_days(release_id, num_of_days)
125 4 |> Repo.all()
126 end
127
128 defp publish_result({:ok, %{package: package, release: release} = result}, user, body) do
129 28 release = %{release | package: package}
130
131 28 Assets.push_release(release, body)
132 28 update_package_in_registry(package)
133 28 email_package_owners(package, release, user)
134
135 {:ok, %{result | release: release, package: package}}
136 end
137
138 14 defp publish_result(result, _user, _body), do: result
139
140 defp retire_result({:ok, %{package: package}}) do
141 6 RegistryBuilder.package(package)
142 :ok
143 end
144
145 0 defp retire_result(result), do: result
146
147 defp revert_result({:ok, %{package: package, release: release, release_count: 0}}) do
148 6 remove_package_from_registry(package)
149 6 Assets.revert_release(release)
150 :ok
151 end
152
153 defp revert_result({:ok, %{package: package, release: release, release_count: _}}) do
154 3 update_package_in_registry(package)
155 3 Assets.revert_release(release)
156 :ok
157 end
158
159 2 defp revert_result(result), do: result
160
161 defp create_package(multi, repository, package, user, meta) do
162 42 changeset =
163 if package do
164 24 params = %{"meta" => meta}
165 24 Package.update(package, params)
166 else
167 18 params = %{"name" => meta["name"], "meta" => meta}
168 18 Package.build(repository, user, params)
169 end
170
171 42 Multi.insert_or_update(multi, :package, fn %{reserved_packages: reserved_packages} ->
172 40 validate_reserved_package(changeset, reserved_packages)
173 end)
174 end
175
176 defp create_release(multi, package, user, inner_checksum, outer_checksum, meta, replace?) do
177 42 version = meta["version"]
178
179 # Validate version manually to avoid an Ecto.Query.CastError exception
180 # which would return an opaque 400 HTTP status
181 42 case Version.parse(version) do
182 {:ok, version} ->
183 40 params = %{
184 "app" => meta["app"],
185 "version" => version,
186 "requirements" => normalize_requirements(meta["requirements"]),
187 "meta" => meta
188 }
189
190 40 release = package && Repo.get_by(assoc(package, :releases), version: version)
191
192 multi
193 |> Multi.insert_or_update(:release, fn changes ->
194 36 %{package: package, reserved_packages: reserved_packages} = changes
195
196 36 changeset =
197 if release do
198 %{release | package: package}
199 |> preload([:requirements, :publisher])
200 11 |> Release.update(user, params, inner_checksum, outer_checksum, replace?)
201 else
202 25 Release.build(package, user, params, inner_checksum, outer_checksum, replace?)
203 end
204
205 36 validate_reserved_version(changeset, reserved_packages)
206 end)
207 40 |> Multi.run(:action, fn _, _ -> {:ok, if(release, do: :update, else: :insert)} end)
208
209 :error ->
210 2 params = %{version: Hexpm.Version}
211 2 change = Ecto.Changeset.cast({%{}, params}, %{version: version}, ~w(version)a)
212 2 Ecto.Multi.error(multi, :version, change)
213 end
214 end
215
216 defp refresh_package_dependants(multi) do
217 53 Multi.run(multi, :refresh, fn repo, _ ->
218 37 :ok = repo.refresh_view(Hexpm.Repository.PackageDependant)
219 {:ok, :refresh}
220 end)
221 end
222
223 10 defp release_count(repo, %{release: release}) do
224 10 {:ok, repo.aggregate(assoc(release.package, :releases), :count, :id)}
225 end
226
227 defp maybe_delete_package(repo, %{release_count: release_count, release: release}) do
228 10 if release_count == 0 do
229 7 release.package
230 |> Package.delete()
231 7 |> repo.delete()
232 else
233 3 {:ok, release.package}
234 end
235 end
236
237 defp email_package_owners(package, release, publisher) do
238 Hexpm.Repo.all(assoc(package, :owners))
239 |> Hexpm.Repo.preload([:emails, organization: [users: :emails]])
240 28 |> Emails.package_published(publisher, package.name, release.version)
241 28 |> Mailer.deliver_later!()
242 end
243
244 if Mix.env() == :test do
245 defp update_package_in_registry(package) do
246 31 RegistryBuilder.package(package)
247 31 RegistryBuilder.repository(package.repository)
248 end
249
250 defp remove_package_from_registry(package) do
251 6 RegistryBuilder.package_delete(package)
252 6 RegistryBuilder.repository(package.repository)
253 end
254 else
255 defp update_package_in_registry(package) do
256 RegistryBuilder.package(package)
257 metadata = Logger.metadata()
258
259 Task.Supervisor.start_child(Hexpm.Tasks, fn ->
260 Logger.metadata(metadata)
261 RegistryBuilder.repository(package.repository)
262 end)
263 end
264
265 defp remove_package_from_registry(package) do
266 RegistryBuilder.package_delete(package)
267 metadata = Logger.metadata()
268
269 Task.Supervisor.start_child(Hexpm.Tasks, fn ->
270 Logger.metadata(metadata)
271 RegistryBuilder.repository(package.repository)
272 end)
273 end
274 end
275
276 defp reserved_packages(repository, %{"name" => name}) when is_binary(name) do
277 from(
278 r in "reserved_packages",
279 40 where: r.repository_id == ^repository.id,
280 where: r.name == ^name,
281 select: r.version
282 )
283 |> Repo.all()
284 40 |> Enum.map(fn version ->
285 2 if version do
286 1 {:ok, version} = Version.parse(version)
287 1 version
288 end
289 end)
290 end
291
292 0 defp reserved_packages(_repository, _meta) do
293 []
294 end
295
296 defp validate_reserved_package(changeset, reserved) do
297 40 if nil in reserved do
298 1 validate_exclusion(changeset, :name, [get_field(changeset, :name)])
299 else
300 39 changeset
301 end
302 end
303
304 defp validate_reserved_version(changeset, reserved) do
305 36 validate_exclusion(changeset, :version, reserved)
306 end
307
308 defp audit_publish(multi, audit_data) do
309 42 audit(multi, audit_data, "release.publish", fn %{package: pkg, release: rel} -> {pkg, rel} end)
310 end
311
312 defp audit_revert(multi, audit_data, package, release) do
313 11 audit(multi, audit_data, "release.revert", {package, release})
314 end
315
316 defp audit_retire(multi, audit_data, package) do
317 3 audit(multi, audit_data, "release.retire", fn %{release: rel} -> {package, rel} end)
318 end
319
320 defp audit_unretire(multi, audit_data, package) do
321 3 audit(multi, audit_data, "release.unretire", fn %{release: rel} -> {package, rel} end)
322 end
323
324 defp normalize_requirements(requirements) when is_map(requirements) do
325 35 Enum.map(requirements, fn
326 {name, map} when is_map(map) ->
327 5 Map.put(map, "name", name)
328
329 other ->
330 0 other
331 end)
332 end
333
334 5 defp normalize_requirements(requirements), do: requirements
335
336 28 defp preload_field(release, :requirements), do: {:requirements, Release.requirements(release)}
337 9 defp preload_field(release, :downloads), do: {:downloads, ReleaseDownload.release(release)}
338 28 defp preload_field(_release, :publisher), do: {:publisher, [:emails, :organization]}
339 end

lib/hexpm/repository/repositories.ex

100
2
186
0
Line Hits Source
0 defmodule Hexpm.Repository.Repositories do
1 use Hexpm.Context
2
3 2 def all_public() do
4 [Repository.hexpm()]
5 end
6
7 def get(name, preload \\ []) do
8 Repo.get_by(Repository, name: name)
9 184 |> Repo.preload(preload)
10 end
11 end

lib/hexpm/repository/repository.ex

100
5
1520
0
Line Hits Source
0 defmodule Hexpm.Repository.Repository do
1 use Hexpm.Schema
2
3 @derive HexpmWeb.Stale
4 @derive {Phoenix.Param, key: :name}
5
6 1277 schema "repositories" do
7 field :name, :string
8 timestamps()
9
10 belongs_to :organization, Organization
11 has_many :packages, Package
12 end
13
14 def hexpm(opts \\ []) do
15 81 organization =
16 if Keyword.get(opts, :recursive, true) do
17 43 Organization.hexpm(recursive: false)
18 else
19 38 %Ecto.Association.NotLoaded{}
20 end
21
22 81 %__MODULE__{
23 id: 1,
24 name: "hexpm",
25 organization: organization,
26 organization_id: 1
27 }
28 end
29 end

lib/hexpm/repository/requirement.ex

95.7
23
1652
1
Line Hits Source
0 defmodule Hexpm.Repository.Requirement do
1 use Hexpm.Schema
2 require Logger
3
4 @derive {HexpmWeb.Stale, last_modified: nil}
5
6 610 schema "requirements" do
7 field :app, :string
8 field :requirement, :string
9 field :optional, :boolean, default: false
10
11 # The repository and name of the dependency used to find the package
12 field :repository, :string, virtual: true
13 field :name, :string, virtual: true
14
15 belongs_to :release, Release
16 belongs_to :dependency, Package
17 end
18
19 def changeset(requirement, params, dependencies, package) do
20 23 repository = params["repository"] || "hexpm"
21
22 cast(requirement, params, ~w(repository name app requirement optional)a)
23 |> put_assoc(:dependency, dependencies[{repository, params["name"]}])
24 |> validate_required(~w(name app requirement optional)a)
25 |> validate_required(
26 :dependency,
27 23 message: "package does not exist in repository \"#{repository}\""
28 )
29 |> validate_requirement(:requirement)
30 23 |> validate_repository(:repository, repository: package.repository)
31 end
32
33 def build_all(release_changeset, package) do
34 90 dependencies = preload_dependencies(release_changeset.params["requirements"])
35
36 90 release_changeset =
37 cast_assoc(
38 release_changeset,
39 :requirements,
40 23 with: &changeset(&1, &2, dependencies, package)
41 )
42
43 90 if release_changeset.valid? do
44 69 requirements =
45 get_change(release_changeset, :requirements, [])
46 |> Enum.map(&apply_changes/1)
47
48 69 validate_resolver(release_changeset, requirements)
49 else
50 21 release_changeset
51 end
52 end
53
54 defp validate_resolver(release_changeset, _requirements) do
55 69 release_changeset
56 end
57
58 # Disabled because of bug
59 # defp validate_resolver(%{valid?: true} = release_changeset, requirements) do
60 # build_tools = get_field(release_changeset, :meta).build_tools
61 #
62 # {time, release_changeset} =
63 # :timer.tc(fn ->
64 # case Resolver.run(requirements, build_tools) do
65 # :ok ->
66 # release_changeset
67 #
68 # {:error, reason} ->
69 # add_error(release_changeset, :requirements, reason)
70 # end
71 # end)
72 #
73 # Logger.warn("DEPENDENCY_RESOLUTION_COMPLETED (#{div(time, 1000)}ms)")
74 # release_changeset
75 # end
76 #
77 # defp validate_resolver(%{valid?: false} = release_changeset, _requirements) do
78 # release_changeset
79 # end
80
81 defp preload_dependencies(requirements) do
82 90 names = requirement_names(requirements)
83
84 from(
85 p in Package,
86 join: r in assoc(p, :repository),
87 select: {{r.name, p.name}, %{p | repository: r}}
88 )
89 90 |> filter_dependencies(names)
90 end
91
92 defp filter_dependencies(_query, []) do
93 70 %{}
94 end
95
96 defp filter_dependencies(query, names) do
97 import Ecto.Query, only: [or_where: 3]
98
99 Enum.reduce(names, query, fn {repository, package}, query ->
100 23 or_where(query, [p, r], r.name == ^repository and p.name == ^package)
101 end)
102 |> Hexpm.Repo.all()
103 20 |> Map.new()
104 end
105
106 defp requirement_names(requirements) when is_list(requirements) do
107 83 Enum.flat_map(requirements, fn
108 req when is_map(req) ->
109 23 name = req["name"]
110 23 repository = req["repository"] || "hexpm"
111
112 23 if is_binary(name) and is_binary(repository) do
113 [{repository, name}]
114 else
115 []
116 end
117
118 0 _ ->
119 []
120 end)
121 end
122
123 7 defp requirement_names(_requirements), do: []
124 end

lib/hexpm/repository/resolver.ex

0
58
0
58
Line Hits Source
0 defmodule Hexpm.Repository.Resolver do
1 import Ecto.Query, only: [from: 2, or_where: 3]
2
3 @behaviour Hex.Registry
4
5 def run(requirements, build_tools) do
6 0 config = guess_config(build_tools)
7 0 resolve(requirements, config)
8 end
9
10 0 defp resolve(requirements, config) do
11 0 {:ok, _name} = open()
12
13 0 deps = resolve_deps(requirements)
14 0 top_level = Enum.map(deps, &elem(&1, 0))
15 0 requests = resolve_new_requests(requirements, config)
16
17 requests
18 0 |> Enum.map(&{elem(&1, 0), elem(&1, 1)})
19 0 |> prefetch()
20
21 Hex.Resolver.resolve(__MODULE__, requests, deps, top_level, %{}, [])
22 0 |> resolve_result()
23 after
24 0 close()
25 end
26
27 0 defp resolve_result({:ok, _}), do: :ok
28 0 defp resolve_result({:error, {:version, messages}}), do: {:error, remove_ansi_escapes(messages)}
29 0 defp resolve_result({:error, {:repo, messages}}), do: {:error, remove_ansi_escapes(messages)}
30 0 defp resolve_result({:error, messages}), do: {:error, remove_ansi_escapes(messages)}
31
32 defp remove_ansi_escapes(string) do
33 0 String.replace(string, ~r"\e\[[0-9]+[a-zA-Z]", "")
34 end
35
36 defp resolve_deps(requirements) do
37 0 if Version.compare(Hex.version(), "0.18.0-dev") in [:eq, :gt] do
38 0 Map.new(requirements, fn %{app: app} ->
39 {app, {false, %{}}}
40 end)
41 else
42 0 Enum.map(requirements, fn %{repository: repository, app: app} ->
43 0 {repository || "hexpm", app, false, []}
44 end)
45 end
46 end
47
48 defp resolve_new_requests(requirements, config) do
49 0 Enum.map(requirements, fn %{repository: repository, name: name, app: app, requirement: req} ->
50 0 {repository || "hexpm", name, app, req, config}
51 end)
52 end
53
54 defp guess_config(build_tools) when is_list(build_tools) do
55 0 cond do
56 0 "mix" in build_tools -> "mix.exs"
57 0 "rebar" in build_tools -> "rebar.config"
58 0 "rebar3" in build_tools -> "rebar.config"
59 0 "erlang.mk" in build_tools -> "Makefile"
60 0 true -> "TOP CONFIG"
61 end
62 end
63
64 0 defp guess_config(_), do: "TOP CONFIG"
65
66 ### Hex.Registry callbacks ###
67
68 def open(_opts \\ []) do
69 0 tid = :ets.new(__MODULE__, [])
70 0 Process.put(__MODULE__, tid)
71 {:ok, tid}
72 end
73
74 def close(name \\ Process.get(__MODULE__)) do
75 0 Process.delete(__MODULE__)
76
77 0 if :ets.info(name) == :undefined do
78 false
79 else
80 0 :ets.delete(name)
81 end
82 end
83
84 def versions(name \\ Process.get(__MODULE__), repository, package) do
85 0 :ets.lookup_element(name, {:versions, repository, package}, 2)
86 end
87
88 def deps(name \\ Process.get(__MODULE__), repository, package, version) do
89 0 case :ets.lookup(name, {:deps, repository, package, version}) do
90 [{_, deps}] ->
91 0 deps
92
93 [] ->
94 0 release_id = :ets.lookup_element(name, {:release, repository, package, version}, 2)
95
96 0 deps =
97 from(
98 r in Hexpm.Repository.Requirement,
99 join: p in assoc(r, :dependency),
100 join: repo in assoc(p, :repository),
101 where: r.release_id == ^release_id,
102 select: {repo.name, p.name, r.app, r.requirement, r.optional}
103 )
104 |> Hexpm.Repo.all()
105
106 0 :ets.insert(name, {{:deps, repository, package, version}, deps})
107 0 deps
108 end
109 end
110
111 def prefetch(name \\ Process.get(__MODULE__), packages) do
112 0 packages =
113 packages
114 |> Enum.uniq()
115 0 |> Enum.reject(fn {repo, package} -> :ets.member(name, {:versions, repo, package}) end)
116
117 0 load_prefetch(name, packages)
118 end
119
120 0 defp load_prefetch(_name, []), do: :ok
121
122 defp load_prefetch(name, packages) do
123 0 packages_query =
124 from(
125 p in Hexpm.Repository.Package,
126 join: r in assoc(p, :repository),
127 select: {p.id, {r.name, p.name}}
128 )
129
130 0 packages =
131 Enum.reduce(packages, packages_query, fn {repository, package}, query ->
132 0 or_where(query, [p, r], r.name == ^repository and p.name == ^package)
133 end)
134 |> Hexpm.Repo.all()
135 |> Map.new()
136
137 0 releases =
138 from(
139 r in Hexpm.Repository.Release,
140 where: r.package_id in ^Map.keys(packages),
141 select: {r.package_id, {r.id, r.version}}
142 )
143 |> Hexpm.Repo.all()
144 0 |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
145
146 0 versions =
147 Enum.map(packages, fn {id, {repo, package}} ->
148 0 versions =
149 releases[id]
150 0 |> Enum.map(&elem(&1, 1))
151 0 |> Enum.sort(&(Version.compare(&1, &2) != :gt))
152
153 {{:versions, repo, package}, versions}
154 end)
155
156 0 releases =
157 Enum.flat_map(releases, fn {pid, versions} ->
158 0 Enum.map(versions, fn {rid, vsn} ->
159 0 {repo, package} = packages[pid]
160 {{:release, repo, package, vsn}, rid}
161 end)
162 end)
163
164 0 :ets.insert(name, versions ++ releases)
165 end
166 end

lib/hexpm/repository/sitemaps.ex

100
7
7
0
Line Hits Source
0 defmodule Hexpm.Repository.Sitemaps do
1 use Hexpm.Context
2
3 def packages() do
4 from(
5 p in Package,
6 where: p.repository_id == 1,
7 order_by: p.name,
8 select: {p.name, p.updated_at}
9 )
10 1 |> Repo.all()
11 end
12
13 def packages_with_docs() do
14 from(
15 p in Package,
16 join: r in assoc(p, :releases),
17 order_by: p.name,
18 where: p.repository_id == 1,
19 where: not is_nil(p.docs_updated_at),
20 where: r.has_docs,
21 select: {p.name, p.docs_updated_at},
22 distinct: true
23 )
24 1 |> Repo.all()
25 end
26
27 def packages_for_preview() do
28 1 releases_query = from(Release, select: [:version, :retirement])
29
30 1 query =
31 from(Package,
32 order_by: :name,
33 where: [repository_id: 1],
34 select: [:id, :name, :updated_at],
35 preload: [releases: ^releases_query]
36 )
37
38 1 for package <- Repo.all(query) do
39 1 version = Release.latest_version(package.releases, only_stable: false).version
40 1 {package.name, version, package.updated_at}
41 end
42 end
43 end

lib/hexpm/schema.ex

0
0
0
0
Line Hits Source
0 defmodule Hexpm.Schema do
1 defmacro __using__(_opts) do
2 quote do
3 use Ecto.Schema
4 @timestamps_opts [type: :utc_datetime_usec]
5
6 import Ecto
7 import Ecto.Changeset
8 import Ecto.Query, only: [from: 1, from: 2]
9 import Hexpm.Changeset
10
11 alias Ecto.Multi
12
13 use Hexpm.Shared
14 end
15 end
16 end

lib/hexpm/shared.ex

0
0
0
0
Line Hits Source
0 defmodule Hexpm.Shared do
1 defmacro __using__(_opts) do
2 quote do
3 alias Hexpm.{
4 Accounts.AuditLog,
5 Accounts.AuditLogs,
6 Accounts.Auth,
7 Accounts.Email,
8 Accounts.Key,
9 Accounts.KeyPermission,
10 Accounts.Keys,
11 Accounts.Organization,
12 Accounts.Organizations,
13 Accounts.OrganizationUser,
14 Accounts.PasswordReset,
15 Accounts.Session,
16 Accounts.User,
17 Accounts.UserHandles,
18 Accounts.Users,
19 Emails,
20 Emails.Mailer,
21 Repository.Assets,
22 Repository.Download,
23 Repository.Install,
24 Repository.Installs,
25 Repository.Owners,
26 Repository.Package,
27 Repository.PackageDownload,
28 Repository.PackageMetadata,
29 Repository.PackageOwner,
30 Repository.PackageReport,
31 Repository.PackageReportComment,
32 Repository.PackageReportRelease,
33 Repository.PackageReports,
34 Repository.Packages,
35 Repository.RegistryBuilder,
36 Repository.Release,
37 Repository.ReleaseDownload,
38 Repository.ReleaseMetadata,
39 Repository.ReleaseRetirement,
40 Repository.Releases,
41 Repository.Repositories,
42 Repository.Repository,
43 Repository.Requirement,
44 Repository.Resolver,
45 Repository.Sitemaps
46 }
47 end
48 end
49 end

lib/hexpm/short_urls/short_url.ex

86.7
15
268
2
Line Hits Source
0 defmodule Hexpm.ShortURLs.ShortURL do
1 use Hexpm.Schema
2
3 alias Hexpm.ShortURLs.ShortURL
4 alias Hexpm.Repo
5
6 13 schema "short_urls" do
7 field :url, :string
8 field :short_code, :string
9
10 timestamps(updated_at: false)
11 end
12
13 def changeset(params) do
14 %ShortURL{}
15 |> cast(params, [:url])
16 |> validate_required([:url])
17 |> ensure_url_domain()
18 |> put_change(:short_code, generate_random(5))
19 |> validate_required(:short_code, message: "could not generate a unique short code")
20 8 |> unique_constraint(:short_code)
21 end
22
23 defp charset do
24 40 capitals = Enum.map(?A..?Z, fn ch -> <<ch>> end)
25 40 lowers = Enum.map(?a..?z, fn ch -> <<ch>> end)
26 40 numbers = Enum.map(?0..?9, fn ch -> <<ch>> end)
27 40 ambiguous = ["I", "0", "O", "l"]
28 40 (capitals ++ lowers ++ numbers) -- ambiguous
29 end
30
31 8 defp generate_random(length, retries \\ 5)
32 0 defp generate_random(_length, 0), do: nil
33
34 defp generate_random(length, retries) do
35 8 short_code = IO.iodata_to_binary(Enum.map(1..length, fn _ -> Enum.random(charset()) end))
36 # Make sure this short_code is unique before continuing
37 8 if short_code_unique?(short_code), do: short_code, else: generate_random(length, retries - 1)
38 end
39
40 defp short_code_unique?(short_code) do
41 8 Repo.get_by(ShortURL, short_code: short_code) |> is_nil()
42 end
43
44 defp ensure_url_domain(changeset) do
45 8 validate_change(changeset, :url, fn :url, url -> hexpm_url?(url) end)
46 end
47
48 0 defp hexpm_url?(nil), do: []
49
50 defp hexpm_url?(url) do
51 7 if URI.parse(url).host =~ ~r/^[\w\.]*hex.pm$/ do
52 []
53 else
54 [url: "domain must match hex.pm or *.hex.pm"]
55 end
56 end
57 end

lib/hexpm/short_urls/short_urls.ex

100
2
7
0
Line Hits Source
0 defmodule Hexpm.ShortURLs do
1 use Hexpm.Context
2 alias Hexpm.ShortURLs.ShortURL
3
4 def add(params) do
5 params
6 |> ShortURL.changeset()
7 3 |> Repo.insert()
8 end
9
10 def get(short_code) do
11 4 Repo.get_by(ShortURL, short_code: short_code)
12 end
13 end

lib/hexpm/store/gcs.ex

0
35
0
35
Line Hits Source
0 defmodule Hexpm.Store.GCS do
1 import SweetXml, only: [sigil_x: 2]
2 require Logger
3
4 @behaviour Hexpm.Store
5
6 @gs_xml_url "https://storage.googleapis.com"
7
8 def list(bucket, prefix) do
9 0 list_stream(bucket, prefix)
10 end
11
12 def get(bucket, key, _opts) do
13 0 url = url(bucket, key)
14
15 0 case Hexpm.HTTP.retry(fn -> Hexpm.HTTP.get(url, headers()) end, "gcs") do
16 0 {:ok, 200, _headers, body} -> body
17 0 _ -> nil
18 end
19 end
20
21 def put(bucket, key, blob, opts) do
22 0 headers =
23 headers() ++
24 meta_headers(Keyword.fetch!(opts, :meta)) ++
25 [
26 {"cache-control", Keyword.fetch!(opts, :cache_control)},
27 {"content-type", Keyword.get(opts, :content_type)}
28 ]
29
30 0 url = url(bucket, key)
31 0 headers = filter_nil_values(headers)
32
33 0 {:ok, 200, _headers, _body} =
34 0 Hexpm.HTTP.retry(fn -> Hexpm.HTTP.put(url, headers, blob) end, "gcs")
35
36 :ok
37 end
38
39 def delete_many(bucket, keys) do
40 keys
41 |> Task.async_stream(
42 0 &delete(bucket, &1),
43 max_concurrency: 10,
44 timeout: 10_000
45 )
46 0 |> Stream.run()
47 end
48
49 def delete(bucket, key) do
50 0 url = url(bucket, key)
51
52 0 {:ok, 204, _headers, _body} =
53 0 Hexpm.HTTP.retry(fn -> Hexpm.HTTP.delete(url, headers()) end, "gcs")
54
55 :ok
56 end
57
58 defp list_stream(bucket, prefix) do
59 0 start_fun = fn -> nil end
60 0 after_fun = fn _ -> nil end
61
62 0 next_fun = fn
63 0 :halt ->
64 {:halt, nil}
65
66 marker ->
67 0 {items, marker} = do_list(bucket, prefix, marker)
68 0 {items, marker || :halt}
69 end
70
71 0 Stream.resource(start_fun, next_fun, after_fun)
72 end
73
74 defp do_list(bucket, prefix, marker) do
75 0 url = url(bucket) <> "?prefix=#{prefix}&marker=#{marker}"
76
77 0 {:ok, 200, _headers, body} = Hexpm.HTTP.retry(fn -> Hexpm.HTTP.get(url, headers()) end, "gcs")
78
79 0 doc = SweetXml.parse(body)
80 0 marker = SweetXml.xpath(doc, ~x"/ListBucketResult/NextMarker/text()"s)
81 0 items = SweetXml.xpath(doc, ~x"/ListBucketResult/Contents/Key/text()"ls)
82 0 marker = if marker != "", do: marker
83
84 {items, marker}
85 end
86
87 defp filter_nil_values(keyword) do
88 0 Enum.reject(keyword, fn {_key, value} -> is_nil(value) end)
89 end
90
91 defp headers() do
92 0 {:ok, token} = Goth.fetch(Hexpm.Goth)
93 0 [{"authorization", "#{token.type} #{token.token}"}]
94 end
95
96 defp meta_headers(meta) do
97 0 Enum.map(meta, fn {key, value} ->
98 0 {"x-goog-meta-#{key}", value}
99 end)
100 end
101
102 defp url(bucket) do
103 0 @gs_xml_url <> "/" <> bucket
104 end
105
106 defp url(bucket, key) do
107 0 url(bucket) <> "/" <> key
108 end
109 end

lib/hexpm/store/local.ex

100
15
3465
0
Line Hits Source
0 defmodule Hexpm.Store.Local do
1 @behaviour Hexpm.Store
2
3 # only used during development (not safe)
4
5 def list(bucket, prefix) do
6 8 relative = Path.join([dir(), bucket])
7 8 paths = Path.join(relative, "**") |> Path.wildcard()
8
9 8 Enum.flat_map(paths, fn path ->
10 1116 relative = Path.relative_to(path, relative)
11
12 1116 if String.starts_with?(relative, prefix) and File.regular?(path) do
13 [relative]
14 else
15 []
16 end
17 end)
18 end
19
20 def get(bucket, key, _opts) do
21 39 path = Path.join([dir(), bucket, key])
22
23 39 case File.read(path) do
24 30 {:ok, contents} -> contents
25 9 {:error, :enoent} -> nil
26 end
27 end
28
29 def put(bucket, key, blob, _opts) do
30 233 path = Path.join([dir(), bucket, key])
31 233 File.mkdir_p!(Path.dirname(path))
32 233 File.write!(path, blob)
33 end
34
35 def delete(bucket, key) do
36 [dir(), bucket, key]
37 |> Path.join()
38 53 |> File.rm()
39 end
40
41 def delete_many(bucket, keys) do
42 7 Enum.each(keys, &delete(bucket, &1))
43 end
44
45 defp dir() do
46 Application.get_env(:hexpm, :tmp_dir)
47 333 |> Path.join("store")
48 end
49 end

lib/hexpm/store/s3.ex

0
10
0
10
Line Hits Source
0 defmodule Hexpm.Store.S3 do
1 @behaviour Hexpm.Store
2
3 alias ExAws.S3
4
5 def list(bucket, prefix) do
6 S3.list_objects(bucket(bucket), prefix: prefix)
7 |> ExAws.stream!(region: region(bucket))
8 0 |> Stream.map(&Map.get(&1, :key))
9 end
10
11 def get(bucket, key, opts) do
12 S3.get_object(bucket(bucket), key, opts)
13 |> ExAws.request(region: region(bucket))
14 0 |> case do
15 0 {:ok, %{body: body}} -> body
16 0 {:error, {:http_error, 404, _}} -> nil
17 end
18 end
19
20 def put(bucket, key, blob, opts) do
21 S3.put_object(bucket(bucket), key, blob, opts)
22 0 |> ExAws.request!(region: region(bucket))
23 end
24
25 def delete(bucket, key) do
26 S3.delete_object(bucket(bucket), key)
27 0 |> ExAws.request!(region: region(bucket))
28 end
29
30 def delete_many(bucket, keys) do
31 # AWS doesn't like concurrent delete requests
32 keys
33 |> Stream.chunk_every(1000, 1000, [])
34 0 |> Enum.each(fn chunk ->
35 S3.delete_multiple_objects(bucket(bucket), chunk)
36 0 |> ExAws.request!(region: region(bucket))
37 end)
38 end
39
40 defp bucket(binary) when is_binary(binary) do
41 0 Enum.at(String.split(binary, ",", parts: 2), 1)
42 end
43
44 defp region(binary) when is_binary(binary) do
45 0 Enum.at(String.split(binary, ",", parts: 2), 0)
46 end
47 end

lib/hexpm/store/store.ex

75
16
1237
4
Line Hits Source
0 defmodule Hexpm.Store do
1 @type bucket :: String.t() | {module, String.t()}
2 @type prefix :: key
3 @type key :: String.t()
4 @type body :: binary
5 @type opts :: Keyword.t()
6
7 @callback list(bucket, prefix) :: [key]
8 @callback get(bucket, key, opts) :: body | nil
9 @callback put(bucket, key, body, opts) :: term
10 @callback delete(bucket, key) :: term
11 @callback delete_many(bucket, [key]) :: :ok
12
13 defp impl_bucket(atom) when is_atom(atom) do
14 307 impl_bucket(Application.get_env(:hexpm, atom))
15 end
16
17 310 defp impl_bucket({impl, bucket}) when is_atom(impl) do
18 {impl, bucket}
19 end
20
21 defp impl_bucket(bucket) when is_binary(bucket) do
22 0 case String.split(bucket, ",", parts: 2) do
23 0 ["local", bucket] -> {Hexpm.Store.Local, bucket}
24 0 ["s3", bucket] -> {Hexpm.Store.S3, bucket}
25 0 ["gcs", bucket] -> {Hexpm.Store.GCS, bucket}
26 end
27 end
28
29 def list(bucket, prefix) do
30 8 {impl, bucket} = impl_bucket(bucket)
31 8 impl.list(bucket, prefix)
32 end
33
34 def get(bucket, key, opts) do
35 39 {impl, bucket} = impl_bucket(bucket)
36 39 impl.get(bucket, key, opts)
37 end
38
39 def put(bucket, key, body, opts) do
40 233 {impl, bucket} = impl_bucket(bucket)
41 233 impl.put(bucket, key, body, opts)
42 end
43
44 def delete(bucket, key) do
45 23 {impl, bucket} = impl_bucket(bucket)
46 23 impl.delete(bucket, key)
47 end
48
49 def delete_many(bucket, keys) do
50 7 {impl, bucket} = impl_bucket(bucket)
51 7 impl.delete_many(bucket, keys)
52 end
53 end

lib/hexpm/utils.ex

69.1
97
18548
30
Line Hits Source
0 defmodule Hexpm.Utils do
1 @moduledoc """
2 Assorted utility functions.
3 """
4
5 @timeout 60 * 60 * 1000
6
7 import Ecto.Query, only: [from: 2]
8 alias Hexpm.Repository.{Package, Release, Repository}
9 require Logger
10
11 def secure_check(left, right) do
12 254 if byte_size(left) == byte_size(right) do
13 252 secure_check(left, right, 0) == 0
14 else
15 false
16 end
17 end
18
19 defp secure_check(<<left, left_rest::binary>>, <<right, right_rest::binary>>, acc) do
20 8064 secure_check(left_rest, right_rest, Bitwise.bor(acc, Bitwise.bxor(left, right)))
21 end
22
23 defp secure_check(<<>>, <<>>, acc) do
24 252 acc
25 end
26
27 def multi_task(args, fun) do
28 args
29 |> multi_async(fun)
30 0 |> multi_await()
31 end
32
33 def multi_task(funs) do
34 funs
35 |> multi_async()
36 0 |> multi_await()
37 end
38
39 def multi_async(args, fun) do
40 0 Enum.map(args, fn arg -> Task.async(fn -> fun.(arg) end) end)
41 end
42
43 def multi_async(funs) do
44 0 Enum.map(funs, &Task.async/1)
45 end
46
47 def multi_await(tasks) do
48 0 Enum.map(tasks, &Task.await(&1, @timeout))
49 end
50
51 0 def maybe(nil, _fun), do: nil
52 0 def maybe(item, fun), do: fun.(item)
53
54 def log_error(kind, error, stacktrace) do
55 0 Logger.error(
56 Exception.format_banner(kind, error, stacktrace) <>
57 "\n" <> Exception.format_stacktrace(stacktrace)
58 )
59 end
60
61 def utc_yesterday() do
62 1 utc_days_ago(1)
63 end
64
65 def utc_days_ago(days) do
66 4 {today, _time} = :calendar.universal_time()
67
68 today
69 |> :calendar.date_to_gregorian_days()
70 |> Kernel.-(days)
71 |> :calendar.gregorian_days_to_date()
72 4 |> Date.from_erl!()
73 end
74
75 def safe_to_atom(binary, allowed) do
76 26 if binary in allowed, do: String.to_atom(binary)
77 end
78
79 0 def safe_page(page, _count, _per_page) when page < 1 do
80 1
81 end
82
83 def safe_page(page, count, per_page) when page > div(count, per_page) + 1 do
84 0 div(count, per_page) + 1
85 end
86
87 def safe_page(page, _count, _per_page) do
88 8 page
89 end
90
91 23 def safe_int(nil), do: nil
92
93 def safe_int(string) do
94 3 case Integer.parse(string) do
95 3 {int, ""} -> int
96 0 _ -> nil
97 end
98 end
99
100 18 def parse_search(nil), do: nil
101 0 def parse_search(""), do: nil
102 8 def parse_search(search), do: String.trim(search)
103
104 defp diff(a, b) do
105 13 {days, time} = :calendar.time_difference(a, b)
106 13 :calendar.time_to_seconds(time) - days * 24 * 60 * 60
107 end
108
109 @doc """
110 Determine if a given timestamp is less than a day (86400 seconds) old
111 """
112 1 def within_last_day?(nil), do: false
113
114 def within_last_day?(a) do
115 13 diff = diff(NaiveDateTime.to_erl(a), :calendar.universal_time())
116
117 13 diff < 24 * 60 * 60
118 end
119
120 0 def etag(nil), do: nil
121 0 def etag([]), do: nil
122
123 def etag(models) do
124 0 list =
125 0 Enum.map(List.wrap(models), fn model ->
126 0 [model.__struct__, model.id, model.updated_at]
127 end)
128
129 0 binary = :erlang.term_to_binary(list)
130
131 :crypto.hash(:md5, binary)
132 0 |> Base.encode16(case: :lower)
133 end
134
135 0 def last_modified(nil), do: nil
136 0 def last_modified([]), do: nil
137
138 def last_modified(models) do
139 0 list =
140 Enum.map(List.wrap(models), fn model ->
141 0 NaiveDateTime.to_erl(model.updated_at)
142 end)
143
144 0 Enum.max(list)
145 end
146
147 4 def binarify(term, opts \\ [])
148
149 366 def binarify(binary, _opts) when is_binary(binary), do: binary
150 0 def binarify(number, _opts) when is_number(number), do: number
151 5 def binarify(atom, _opts) when is_nil(atom) or is_boolean(atom), do: atom
152 418 def binarify(atom, _opts) when is_atom(atom), do: Atom.to_string(atom)
153 152 def binarify(list, opts) when is_list(list), do: for(elem <- list, do: binarify(elem, opts))
154 0 def binarify(%Version{} = version, _opts), do: to_string(version)
155
156 def binarify(%DateTime{} = dt, _opts),
157 3 do: dt |> DateTime.truncate(:second) |> DateTime.to_iso8601()
158
159 def binarify(%NaiveDateTime{} = ndt, _opts),
160 0 do: ndt |> NaiveDateTime.truncate(:second) |> NaiveDateTime.to_iso8601()
161
162 def binarify(%{__struct__: atom}, _opts) when is_atom(atom),
163 0 do: raise("not able to binarify %#{inspect(atom)}{}")
164
165 def binarify(tuple, opts) when is_tuple(tuple),
166 420 do: for(elem <- Tuple.to_list(tuple), do: binarify(elem, opts)) |> List.to_tuple()
167
168 def binarify(map, opts) when is_map(map) do
169 101 if Keyword.get(opts, :maps, true) do
170 1 for(elem <- map, into: %{}, do: binarify(elem, opts))
171 else
172 100 for(elem <- map, do: binarify(elem, opts))
173 end
174 end
175
176 @doc """
177 Returns a url to a resource on the CDN from a list of path components.
178 """
179 @spec cdn_url([String.t()] | String.t()) :: String.t()
180 def cdn_url(path) do
181 9 Application.get_env(:hexpm, :cdn_url) <> "/" <> Path.join(List.wrap(path))
182 end
183
184 @doc """
185 Returns a url to a resource on the docs site from a list of path components.
186 """
187 @spec docs_html_url(Repository.t(), Package.t(), Release.t() | nil) :: String.t()
188 def docs_html_url(%Repository{id: 1}, package, release) do
189 39 docs_url = Application.get_env(:hexpm, :docs_url)
190 39 package = package.name
191 39 version = release && "#{release.version}/"
192 39 "#{docs_url}/#{package}/#{version}"
193 end
194
195 def docs_html_url(%Repository{} = repository, package, release) do
196 6 docs_url = URI.parse(Application.get_env(:hexpm, :docs_url))
197 6 docs_url = %{docs_url | host: "#{repository.name}.#{docs_url.host}"}
198 6 package = package.name
199 6 version = release && "#{release.version}/"
200 6 "#{docs_url}/#{package}/#{version}"
201 end
202
203 @doc """
204 Returns a url to the documentation tarball in the Amazon S3 Hex.pm bucket.
205 """
206 @spec docs_tarball_url(Repository.t(), Package.t(), Release.t()) :: String.t()
207 def docs_tarball_url(%Repository{id: 1}, package, release) do
208 10 repo = Application.get_env(:hexpm, :cdn_url)
209 10 package = package.name
210 10 version = release.version
211 10 "#{repo}/docs/#{package}-#{version}.tar.gz"
212 end
213
214 def docs_tarball_url(%Repository{} = repository, package, release) do
215 6 cdn_url = Application.get_env(:hexpm, :cdn_url)
216 6 repository = repository.name
217 6 package = package.name
218 6 version = release.version
219 6 "#{cdn_url}/repos/#{repository}/docs/#{package}-#{version}.tar.gz"
220 end
221
222 def paginate(query, page, count) when is_integer(page) and page > 0 do
223 58 offset = (page - 1) * count
224
225 58 from(
226 var in query,
227 offset: ^offset,
228 limit: ^count
229 )
230 end
231
232 def paginate(query, _page, count) do
233 11 paginate(query, 1, count)
234 end
235
236 def parse_ip(ip) do
237 1241 parts = String.split(ip, ".")
238
239 1241 if length(parts) == 4 do
240 1241 parts = Enum.map(parts, &String.to_integer/1)
241 1241 for part <- parts, into: <<>>, do: <<part>>
242 end
243 end
244
245 def parse_ip_mask(string) do
246 4 case String.split(string, "/") do
247 1 [ip, mask] -> {Hexpm.Utils.parse_ip(ip), String.to_integer(mask)}
248 3 [ip] -> {Hexpm.Utils.parse_ip(ip), 32}
249 end
250 end
251
252 0 def in_ip_range?(_range, nil) do
253 false
254 end
255
256 def in_ip_range?(list, ip) when is_list(list) do
257 1237 Enum.any?(list, &in_ip_range?(&1, ip))
258 end
259
260 def in_ip_range?({range, mask}, ip) do
261 1235 <<range::bitstring-size(mask)>> == <<ip::bitstring-size(mask)>>
262 end
263
264 def previous_version(version, all_versions) do
265 29 case Enum.find_index(all_versions, &(&1 == version)) do
266 0 nil -> nil
267 29 version_index -> Enum.at(all_versions, version_index + 1)
268 end
269 end
270
271 def diff_html_url(package_name, version, previous_version) do
272 17 diff_url = Application.fetch_env!(:hexpm, :diff_url)
273 17 "#{diff_url}/diff/#{package_name}/#{previous_version}..#{version}"
274 end
275
276 def preview_html_url(package_name, version) do
277 30 preview_url = Application.fetch_env!(:hexpm, :preview_url)
278 30 "#{preview_url}/preview/#{package_name}/#{version}"
279 end
280
281 @doc """
282 Returns a RFC 2822 format string from a UTC datetime.
283 """
284 def datetime_to_rfc2822(%DateTime{calendar: Calendar.ISO, time_zone: "Etc/UTC"} = datetime) do
285 17 Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S GMT")
286 end
287 end

lib/hexpm_web/auth_helpers.ex

92.1
101
7916
8
Line Hits Source
0 defmodule HexpmWeb.AuthHelpers do
1 import Plug.Conn
2 import HexpmWeb.ControllerHelpers, only: [render_error: 3]
3
4 alias Hexpm.Accounts.{Auth, Key, Organization, Organizations, User}
5 alias Hexpm.Repository.{Package, Packages, PackageOwner, Repository}
6
7 def authorize(conn, opts) do
8 291 user_or_organization = conn.assigns.current_user || conn.assigns.current_organization
9
10 291 if user_or_organization || opts[:authentication] != :required do
11 286 authorized(conn, user_or_organization, opts[:fun], opts)
12 else
13 5 error(conn, {:error, :missing})
14 end
15 end
16
17 defp authorized(conn, %User{service: true}, _funs, _opts) do
18 1 conn
19 end
20
21 defp authorized(conn, user_or_organization, funs, opts) do
22 285 domain = Keyword.get(opts, :domain)
23 285 resource = Keyword.get(opts, :resource)
24 285 key = conn.assigns.key
25 285 email = conn.assigns.email
26
27 285 cond do
28 not verified_user?(user_or_organization, email, opts) ->
29 1 error(conn, {:error, :unconfirmed})
30
31 284 user_or_organization && !verify_permissions?(key, domain, resource) ->
32 0 error(conn, {:error, :domain})
33
34 284 funs ->
35 Enum.find_value(List.wrap(funs), fn fun ->
36 315 case apply_authorization_fun(fun, conn, user_or_organization, opts[:opts]) do
37 233 :ok -> nil
38 82 other -> error(conn, other)
39 end
40 237 end) || conn
41
42 47 true ->
43 47 conn
44 end
45 end
46
47 defp apply_authorization_fun(fun, conn, user_or_organization, _opts = nil) do
48 227 fun.(conn, user_or_organization)
49 end
50
51 defp apply_authorization_fun(fun, conn, user_or_organization, opts) do
52 88 fun.(conn, user_or_organization, opts)
53 end
54
55 defp verified_user?(%User{}, email, opts) do
56 203 allow_unconfirmed = Keyword.get(opts, :allow_unconfirmed, false)
57 203 allow_unconfirmed || (email && email.verified)
58 end
59
60 27 defp verified_user?(%Organization{}, _email, _opts) do
61 true
62 end
63
64 55 defp verified_user?(nil, _email, _opts) do
65 true
66 end
67
68 4 defp verify_permissions?(nil, _domain, _resource) do
69 true
70 end
71
72 38 defp verify_permissions?(_key, nil, _resource) do
73 true
74 end
75
76 defp verify_permissions?(key, domain, resource) do
77 187 Key.verify_permissions?(key, domain, resource)
78 end
79
80 def error(conn, error) do
81 117 case error do
82 {:error, :missing} ->
83 5 unauthorized(conn, "missing authentication information")
84
85 {:error, :invalid} ->
86 0 unauthorized(conn, "invalid authentication information")
87
88 {:error, :password} ->
89 0 unauthorized(conn, "invalid username and password combination")
90
91 {:error, :key} ->
92 6 unauthorized(conn, "invalid API key")
93
94 {:error, :revoked_key} ->
95 4 unauthorized(conn, "API key revoked")
96
97 {:error, :domain} ->
98 14 unauthorized(conn, "key not authorized for this action")
99
100 {:error, :unconfirmed} ->
101 1 forbidden(conn, "email not verified")
102
103 {:error, :auth} ->
104 11 forbidden(conn, "account not authorized for this action")
105
106 {:error, :auth, reason} ->
107 5 forbidden(conn, reason)
108
109 {:error, :not_found} ->
110 71 HexpmWeb.ControllerHelpers.not_found(conn)
111 end
112 end
113
114 def authenticate(conn) do
115 320 case get_req_header(conn, "authorization") do
116 ["Basic " <> credentials] ->
117 4 basic_auth(credentials)
118
119 [key] ->
120 243 key_auth(key, conn)
121
122 73 _ ->
123 {:error, :missing}
124 end
125 end
126
127 defp basic_auth(credentials) do
128 4 with {:ok, decoded} <- Base.decode64(credentials),
129 4 [username_or_email, password] <- String.split(decoded, ":", parts: 2) do
130 4 case Auth.password_auth(username_or_email, password) do
131 4 {:ok, result} -> {:ok, result}
132 0 :error -> {:error, :password}
133 end
134 else
135 _ ->
136 {:error, :invalid}
137 end
138 end
139
140 defp key_auth(key, conn) do
141 243 case Auth.key_auth(key, usage_info(conn)) do
142 233 {:ok, result} -> {:ok, result}
143 6 :error -> {:error, :key}
144 4 :revoked -> {:error, :revoked_key}
145 end
146 end
147
148 defp usage_info(%{remote_ip: remote_ip} = conn) do
149 243 %{
150 ip: remote_ip,
151 used_at: DateTime.utc_now(),
152 user_agent: get_req_header(conn, "user-agent")
153 }
154 end
155
156 def unauthorized(conn, reason) do
157 conn
158 |> put_resp_header("www-authenticate", "Basic realm=hex")
159 29 |> render_error(401, message: reason)
160 end
161
162 def forbidden(conn, reason) do
163 17 render_error(conn, 403, message: reason)
164 end
165
166 87 def package_owner(conn, user_or_organization, opts \\ [])
167
168 def package_owner(%Plug.Conn{} = conn, user_or_organization, opts) do
169 124 package_owner(conn.assigns.repository, conn.assigns.package, user_or_organization, opts)
170 end
171
172 def package_owner(
173 %Repository{} = repository,
174 %Package{} = package,
175 %Organization{} = organization,
176 opts
177 ) do
178 4 owner_level = opts[:owner_level] || "maintainer"
179
180 4 cond do
181 4 repository.organization_id == organization.id -> :ok
182 2 Packages.owner_with_access?(package, organization.user, owner_level) -> :ok
183 1 repository.id == 1 -> {:error, :auth}
184 1 true -> {:error, :not_found}
185 end
186 end
187
188 def package_owner(%Repository{} = repository, %Package{} = package, %User{} = user, opts) do
189 77 cond do
190 77 Packages.owner_with_access?(package, user, opts[:owner_level] || "maintainer") -> :ok
191 16 repository.id == 1 -> {:error, :auth}
192 9 true -> {:error, :not_found}
193 end
194 end
195
196 def package_owner(%Repository{} = repository, %Package{}, nil, _opts) do
197 4 if repository.id == 1 do
198 {:error, :auth}
199 else
200 {:error, :not_found}
201 end
202 end
203
204 def package_owner(
205 %Repository{} = repository,
206 nil = _package,
207 %Organization{} = organization,
208 _opts
209 ) do
210 1 cond do
211 1 repository.id == 1 -> :ok
212 1 repository.organization_id == organization.id -> :ok
213 0 true -> {:error, :not_found}
214 end
215 end
216
217 def package_owner(%Repository{} = repository, nil = _package, %User{} = user, opts) do
218 26 expected_role = PackageOwner.level_to_organization_role(opts[:owner_level] || "maintainer")
219 26 actual_role = Organizations.get_role(repository.organization, user)
220
221 26 cond do
222 26 repository.id == 1 -> :ok
223 14 actual_role && actual_role in Organization.role_or_higher(expected_role) -> :ok
224 8 actual_role -> {:error, :auth}
225 7 true -> {:error, :not_found}
226 end
227 end
228
229 def package_owner(%Repository{} = repository, nil = _package, nil = _user, _opts) do
230 3 boolean_to_not_found(repository.id == 1)
231 end
232
233 9 def package_owner(nil = _repository, _package, _user, _opts) do
234 {:error, :not_found}
235 end
236
237 83 def organization_access(conn, user_or_organization, opts \\ [])
238
239 def organization_access(%Plug.Conn{} = conn, user_or_organization, opts) do
240 113 organization_access(conn.assigns.organization, user_or_organization, opts)
241 end
242
243 def organization_access(%Organization{id: 1}, _user_or_organization, opts) do
244 21 role = opts[:organization_role] || "read"
245 21 boolean_to_auth_error(role == "read")
246 end
247
248 26 def organization_access(nil = _organization, _user_or_organization, _opts) do
249 :ok
250 end
251
252 def organization_access(%Organization{} = organization, user_or_organization, opts) do
253 66 cond do
254 Organizations.access?(
255 organization,
256 user_or_organization,
257 66 opts[:organization_role] || "read"
258 28 ) ->
259 :ok
260
261 38 organization.id == 1 ->
262 {:error, :auth}
263
264 38 true ->
265 {:error, :not_found}
266 end
267 end
268
269 141 def organization_billing_active(conn, user_or_organization, opts \\ [])
270
271 def organization_billing_active(%Plug.Conn{} = conn, _user_or_organization, _opts) do
272 78 organization_billing_active(conn.assigns.organization, nil)
273 end
274
275 def organization_billing_active(%Organization{} = organization, _user_or_organization, _opts) do
276 84 if organization.id == 1 or Organization.billing_active?(organization) do
277 :ok
278 else
279 5 {:error, :auth, "organization has no active billing subscription"}
280 end
281 end
282
283 0 def organization_billing_active(nil = _organization, _user_or_organization, _opts) do
284 :ok
285 end
286
287 21 defp boolean_to_auth_error(true), do: :ok
288 0 defp boolean_to_auth_error(false), do: {:error, :auth}
289
290 0 defp boolean_to_not_found(true), do: :ok
291 3 defp boolean_to_not_found(false), do: {:error, :not_found}
292 end

lib/hexpm_web/consult_format.ex

20
10
444
8
Line Hits Source
0 defmodule HexpmWeb.ConsultFormat do
1 def encode(map) when is_map(map) do
2 map
3 |> Hexpm.Utils.binarify(maps: false)
4 395 |> Enum.map(&[:io_lib.print(&1) | ".\n"])
5 49 |> IO.iodata_to_binary()
6 end
7
8 def decode(string) when is_binary(string) do
9 0 string = String.to_charlist(string)
10
11 0 case :safe_erl_term.string(string) do
12 {:ok, tokens, _line} ->
13 0 try do
14 0 terms = :safe_erl_term.terms(tokens)
15 0 result = Enum.into(terms, %{})
16 {:ok, result}
17 rescue
18 0 FunctionClauseError ->
19 {:error, "invalid terms"}
20
21 0 ArgumentError ->
22 {:error, "not in key-value format"}
23 end
24
25 0 {:error, reason} ->
26 {:error, inspect(reason)}
27 end
28 end
29 end

lib/hexpm_web/controllers/api/auth_controller.ex

100
11
220
0
Line Hits Source
0 defmodule HexpmWeb.API.AuthController do
1 use HexpmWeb, :controller
2
3 plug :required_params, ["domain"]
4 plug :authorize, authentication: :required
5
6 def show(conn, %{"domain" => domain} = params) do
7 38 key = conn.assigns.key
8 38 user_or_organization = conn.assigns.current_user || conn.assigns.current_organization
9 38 resource = params["resource"]
10
11 38 if Key.verify_permissions?(key, domain, resource) do
12 24 case KeyPermission.verify_permissions(user_or_organization, domain, resource) do
13 {:ok, nil} ->
14 15 send_resp(conn, 204, "")
15
16 {:ok, repository} ->
17 6 case organization_billing_active(repository, user_or_organization) do
18 4 :ok -> send_resp(conn, 204, "")
19 2 error -> error(conn, error)
20 end
21
22 :error ->
23 3 error(conn, {:error, :auth})
24 end
25 else
26 14 error(conn, {:error, :domain})
27 end
28 end
29 end

lib/hexpm_web/controllers/api/docs_controller.ex

100
19
96
0
Line Hits Source
0 defmodule HexpmWeb.API.DocsController do
1 use HexpmWeb, :controller
2
3 plug :fetch_release
4
5 plug :authorize,
6 [
7 domain: "api",
8 resource: "read",
9 fun: [&organization_access/2, &organization_billing_active/2]
10 ]
11 when action in [:show]
12
13 plug :authorize,
14 [
15 domain: "api",
16 resource: "write",
17 fun: [&package_owner/2, &organization_billing_active/2]
18 ]
19 when action in [:create, :delete]
20
21 def show(conn, _params) do
22 3 repository = conn.assigns.repository
23 3 package = conn.assigns.package
24 3 release = conn.assigns.release
25
26 3 if release.has_docs do
27 2 redirect(conn, external: Hexpm.Utils.docs_tarball_url(repository, package, release))
28 else
29 1 not_found(conn)
30 end
31 end
32
33 def create(conn, %{"body" => body}) do
34 7 repository = conn.assigns.repository
35 7 package = conn.assigns.package
36 7 release = conn.assigns.release
37 7 request_id = List.first(get_resp_header(conn, "x-request-id"))
38
39 7 log_tarball(repository.name, package.name, release.version, request_id, body)
40 7 Hexpm.Repository.Releases.publish_docs(package, release, body, audit: audit_data(conn))
41
42 7 location = Hexpm.Utils.docs_tarball_url(repository, package, release)
43
44 conn
45 |> put_resp_header("location", location)
46 |> api_cache(:public)
47 7 |> send_resp(201, "")
48 end
49
50 def delete(conn, _params) do
51 2 Hexpm.Repository.Releases.revert_docs(conn.assigns.release, audit: audit_data(conn))
52
53 conn
54 |> api_cache(:private)
55 2 |> send_resp(204, "")
56 end
57
58 defp log_tarball(repository, package, version, request_id, body) do
59 7 filename = "#{repository}-#{package}-#{version}-#{request_id}.tar.gz"
60 7 key = Path.join(["debug", "docs", filename])
61 7 Hexpm.Store.put(:repo_bucket, key, body, [])
62 end
63 end

lib/hexpm_web/controllers/api/index_controller.ex

100
1
1
0
Line Hits Source
0 defmodule HexpmWeb.API.IndexController do
1 use HexpmWeb, :controller
2
3 def index(conn, _params) do
4 1 render(conn, :index)
5 end
6 end

lib/hexpm_web/controllers/api/key_controller.ex

89.7
29
89
3
Line Hits Source
0 defmodule HexpmWeb.API.KeyController do
1 use HexpmWeb, :controller
2
3 plug :fetch_organization
4
5 plug :authorize,
6 [
7 domain: "api",
8 resource: "write",
9 allow_unconfirmed: true,
10 fun: &organization_access/3,
11 opts: [organization_role: "write"]
12 ]
13 when action == :create
14
15 plug :authorize,
16 [
17 domain: "api",
18 resource: "write",
19 fun: &organization_access/3,
20 authentication: :required,
21 opts: [organization_role: "write"]
22 ]
23 when action in [:delete, :delete_all]
24
25 plug :authorize,
26 [domain: "api", resource: "read", authentication: :required, fun: &organization_access/2]
27 when action in [:index, :show]
28
29 plug :require_organization_path
30
31 def index(conn, _params) do
32 4 user_or_organization = conn.assigns.organization || conn.assigns.current_user
33 4 authing_key = conn.assigns.key
34 4 keys = Keys.all(user_or_organization)
35
36 conn
37 |> api_cache(:private)
38 4 |> render(:index, keys: keys, authing_key: authing_key)
39 end
40
41 def show(conn, %{"name" => name}) do
42 1 user_or_organization = conn.assigns.organization || conn.assigns.current_user
43 1 authing_key = conn.assigns.key
44 1 key = Keys.get(user_or_organization, name)
45
46 1 if key do
47 1 when_stale(conn, key, fn conn ->
48 conn
49 |> api_cache(:private)
50 1 |> render(:show, key: key, authing_key: authing_key)
51 end)
52 else
53 0 not_found(conn)
54 end
55 end
56
57 def create(conn, params) do
58 6 user_or_organization = conn.assigns.organization || conn.assigns.current_user
59 6 authing_key = conn.assigns.key
60
61 6 case Keys.create(user_or_organization, params, audit: audit_data(conn)) do
62 {:ok, %{key: key}} ->
63 4 location = Routes.api_key_url(conn, :show, params["name"])
64
65 conn
66 |> put_resp_header("location", location)
67 |> api_cache(:private)
68 |> put_status(201)
69 4 |> render(:show, key: key, authing_key: authing_key)
70
71 {:error, :key, changeset, _} ->
72 1 validation_failed(conn, changeset)
73 end
74 end
75
76 def delete(conn, %{"name" => name}) do
77 2 user_or_organization = conn.assigns.organization || conn.assigns.current_user
78 2 authing_key = conn.assigns.key
79
80 2 case Keys.revoke(user_or_organization, name, audit: audit_data(conn)) do
81 {:ok, %{key: key}} ->
82 conn
83 |> api_cache(:private)
84 |> put_status(200)
85 2 |> render(:delete, key: key, authing_key: authing_key)
86
87 _ ->
88 0 not_found(conn)
89 end
90 end
91
92 def delete_all(conn, _params) do
93 1 user_or_organization = conn.assigns.organization || conn.assigns.current_user
94 1 key = conn.assigns.key
95 1 {:ok, _} = Keys.revoke_all(user_or_organization, audit: audit_data(conn))
96
97 conn
98 |> put_status(200)
99 1 |> render(:delete, key: Keys.get(key.id), authing_key: key)
100 end
101
102 defp require_organization_path(conn, _opts) do
103 14 if conn.assigns.current_organization && !conn.assigns.organization do
104 0 not_found(conn)
105 else
106 14 conn
107 end
108 end
109 end

lib/hexpm_web/controllers/api/organization_controller.ex

94.4
18
25
1
Line Hits Source
0 defmodule HexpmWeb.API.OrganizationController do
1 use HexpmWeb, :controller
2 alias Hexpm.Billing
3
4 plug :fetch_organization
5
6 plug :authorize,
7 [domain: "api", resource: "read"]
8 when action == :index
9
10 plug :authorize,
11 [domain: "api", resource: "read", fun: &organization_access/2]
12 when action in [:show, :audit_logs]
13
14 plug :authorize,
15 [
16 domain: "api",
17 resource: "write",
18 fun: &organization_access/3,
19 opts: [organization_level: "write"]
20 ]
21 when action == :update
22
23 def index(conn, _params) do
24 2 organizations =
25 2 Organizations.all_by_user(conn.assigns.current_user) ++
26 2 current_organization(conn.assigns.current_organization)
27
28 conn
29 |> api_cache(:private)
30 2 |> render(:index, organizations: organizations)
31 end
32
33 def show(conn, %{"organization" => name}) do
34 1 organization = Organizations.get(name)
35 1 customer = Billing.get(name)
36
37 conn
38 |> api_cache(:private)
39 1 |> render(:show, organization: organization, customer: customer)
40 end
41
42 def update(conn, %{"organization" => name} = params) do
43 2 organization = Organizations.get(name)
44 2 user_count = Organizations.user_count(organization)
45
46 2 if params["seats"] >= user_count do
47 1 {:ok, customer} = Hexpm.Billing.update(organization.name, %{"quantity" => params["seats"]})
48
49 conn
50 |> api_cache(:private)
51 1 |> render(:show, organization: organization, customer: customer)
52 else
53 1 validation_failed(conn, "number of seats cannot be less than number of members")
54 end
55 end
56
57 def audit_logs(conn, params) do
58 1 organization = conn.assigns.organization
59 1 audit_logs = AuditLogs.all_by(organization, Hexpm.Utils.safe_int(params["page"]), 100)
60
61 1 render(conn, :audit_logs, audit_logs: audit_logs)
62 end
63
64 2 defp current_organization(nil), do: []
65 0 defp current_organization(organization), do: [organization]
66 end

lib/hexpm_web/controllers/api/organization_user_controller.ex

88.2
34
55
4
Line Hits Source
0 defmodule HexpmWeb.API.OrganizationUserController do
1 use HexpmWeb, :controller
2
3 plug :fetch_organization
4
5 plug :authorize,
6 [domain: "api", resource: "read", fun: &organization_access/2]
7 when action in [:index, :show]
8
9 plug :authorize,
10 [
11 domain: "api",
12 resource: "write",
13 fun: &organization_access/3,
14 opts: [organization_role: "admin"]
15 ]
16 when action in [:create, :update, :delete]
17
18 def index(conn, %{"organization" => name}) do
19 1 organization = Organizations.get(name, users: :emails)
20
21 conn
22 |> api_cache(:private)
23 1 |> render(:index, organization_users: organization.organization_users)
24 end
25
26 def show(conn, %{"organization" => name, "name" => username}) do
27 1 organization = Organizations.get(name)
28 1 user = Users.public_get(username, [:emails])
29 1 role = user && Organizations.get_role(organization, user)
30
31 1 if role do
32 conn
33 |> api_cache(:private)
34 1 |> render(:show, user: user, role: role)
35 else
36 0 not_found(conn)
37 end
38 end
39
40 def create(conn, %{"organization" => name, "name" => username} = params) do
41 4 organization = Organizations.get(name)
42 4 user_count = Organizations.user_count(organization)
43 4 customer = Hexpm.Billing.get(organization.name)
44
45 4 if customer["quantity"] > user_count do
46 3 if user = Users.public_get(username, [:emails]) do
47 3 params = %{"role" => params["role"]}
48
49 3 case Organizations.add_member(organization, user, params, audit: audit_data(conn)) do
50 {:ok, organization_user} ->
51 1 location = Routes.api_organization_user_url(conn, :show, name, user.username)
52
53 conn
54 |> api_cache(:private)
55 |> put_resp_header("location", location)
56 1 |> render(:show, user: user, role: organization_user.role)
57
58 {:error, :organization_user} ->
59 1 validation_failed(conn, "cannot add an organization as member to an organization")
60
61 {:error, changeset} ->
62 1 validation_failed(conn, changeset)
63 end
64 else
65 0 validation_failed(conn, %{"name" => "unknown user"})
66 end
67 else
68 1 validation_failed(conn, "not enough seats to add member")
69 end
70 end
71
72 def update(conn, %{"organization" => name, "name" => username} = params) do
73 2 organization = Organizations.get(name)
74
75 2 if user = Users.public_get(username, [:emails]) do
76 2 params = %{"role" => params["role"]}
77
78 2 case Organizations.change_role(organization, user, params, audit: audit_data(conn)) do
79 {:ok, organization_user} ->
80 conn
81 |> api_cache(:private)
82 1 |> render(:show, user: user, role: organization_user.role)
83
84 {:error, :last_admin} ->
85 1 validation_failed(conn, "cannot demote last admin member")
86
87 {:error, changeset} ->
88 0 validation_failed(conn, changeset)
89 end
90 else
91 0 not_found(conn)
92 end
93 end
94
95 def delete(conn, %{"organization" => name, "name" => username}) do
96 2 organization = Organizations.get(name)
97 2 user = Users.public_get(username)
98
99 2 case Organizations.remove_member(organization, user, audit: audit_data(conn)) do
100 :ok ->
101 conn
102 |> api_cache(:private)
103 1 |> send_resp(204, "")
104
105 {:error, :last_member} ->
106 1 validation_failed(conn, "cannot remove last member")
107 end
108 end
109 end

lib/hexpm_web/controllers/api/owner_controller.ex

88.6
35
165
4
Line Hits Source
0 defmodule HexpmWeb.API.OwnerController do
1 use HexpmWeb, :controller
2
3 plug :maybe_fetch_package
4
5 plug :authorize,
6 [domain: "api", resource: "read", fun: &organization_access/2]
7 when action in [:index, :show]
8
9 plug :authorize,
10 [
11 domain: "api",
12 resource: "write",
13 fun: [&package_owner/3, &organization_billing_active/3],
14 opts: [owner_level: "full"]
15 ]
16 when action in [:create, :delete]
17
18 def index(conn, _params) do
19 9 if package = conn.assigns.package do
20 4 owners = Owners.all(package, user: :emails)
21
22 conn
23 |> api_cache(:private)
24 4 |> render(:index, owners: owners)
25 else
26 5 not_found(conn)
27 end
28 end
29
30 def show(conn, %{"username" => name}) do
31 6 package = conn.assigns.package
32 6 name = URI.decode_www_form(name)
33 6 user = Users.public_get(name, [:emails])
34
35 6 if package && user do
36 3 if owner = Owners.get(package, user) do
37 conn
38 |> api_cache(:private)
39 2 |> render(:show, owner: owner)
40 else
41 1 not_found(conn)
42 end
43 else
44 3 not_found(conn)
45 end
46 end
47
48 def create(conn, %{"username" => name} = params) do
49 14 if package = conn.assigns.package do
50 13 name = URI.decode_www_form(name)
51 13 new_owner = Users.public_get(name, [:emails])
52
53 13 if new_owner do
54 12 case Owners.add(package, new_owner, params, audit: audit_data(conn)) do
55 {:ok, _owner} ->
56 conn
57 |> api_cache(:private)
58 10 |> send_resp(204, "")
59
60 {:error, :not_member} ->
61 1 validation_failed(conn, %{
62 "username" =>
63 "cannot add owner to private package when the user is not a member of the organization"
64 })
65
66 {:error, :not_organization_transfer} ->
67 1 validation_failed(conn, %{
68 "username" =>
69 "organization ownership can only be transferred, removing all existing owners"
70 })
71
72 {:error, :organization_level} ->
73 0 validation_failed(conn, %{
74 "level" => "ownership level is required to be \"full\" for organization ownership"
75 })
76
77 {:error, :organization_user_conflict} ->
78 0 validation_failed(conn, %{
79 "username" =>
80 "cannot add organization as owner until user account and organization is merged, " <>
81 "please contact support@hex.pm to manually merge accounts"
82 })
83
84 {:error, changeset} ->
85 0 validation_failed(conn, changeset)
86 end
87 else
88 1 not_found(conn)
89 end
90 else
91 1 not_found(conn)
92 end
93 end
94
95 def delete(conn, %{"username" => name}) do
96 6 if package = conn.assigns.package do
97 5 name = URI.decode_www_form(name)
98 5 remove_owner = Users.get(name)
99
100 5 if remove_owner do
101 4 case Owners.remove(package, remove_owner, audit: audit_data(conn)) do
102 :ok ->
103 conn
104 |> api_cache(:private)
105 3 |> send_resp(204, "")
106
107 {:error, :not_owner} ->
108 0 validation_failed(conn, %{"username" => "user is not an owner of package"})
109
110 {:error, :last_owner} ->
111 1 validation_failed(conn, %{"username" => "cannot remove last owner of package"})
112 end
113 else
114 1 not_found(conn)
115 end
116 else
117 1 not_found(conn)
118 end
119 end
120 end

lib/hexpm_web/controllers/api/package_controller.ex

89.3
28
158
3
Line Hits Source
0 defmodule HexpmWeb.API.PackageController do
1 use HexpmWeb, :controller
2
3 plug :fetch_repository when action in [:index]
4 plug :maybe_fetch_package when action in [:show, :audit_logs]
5
6 plug :authorize, domain: "api", resource: "read", fun: &organization_access/2
7
8 @sort_params ~w(name recent_downloads total_downloads inserted_at updated_at)
9
10 def index(conn, params) do
11 10 repositories = repositories(conn)
12 10 page = Hexpm.Utils.safe_int(params["page"])
13 10 search = Hexpm.Utils.parse_search(params["search"])
14 10 sort = sort(params["sort"])
15 10 packages = Packages.search_with_versions(repositories, page, 100, search, sort)
16
17 10 when_stale(conn, packages, [modified: false], fn conn ->
18 conn
19 |> api_cache(:public)
20 10 |> render(:index, packages: packages)
21 end)
22 end
23
24 def show(conn, _params) do
25 6 if package = conn.assigns.package do
26 3 when_stale(conn, package, fn conn ->
27 3 package = Packages.preload(package)
28 3 owners = Enum.map(Owners.all(package, user: :emails), & &1.user)
29 3 package = %{package | owners: owners}
30
31 conn
32 |> api_cache(:public)
33 3 |> render(:show, package: package)
34 end)
35 else
36 3 not_found(conn)
37 end
38 end
39
40 def audit_logs(conn, params) do
41 1 if package = conn.assigns.package do
42 1 audit_logs = AuditLogs.all_by(package, Hexpm.Utils.safe_int(params["page"]), 100)
43
44 1 render(conn, :audit_logs, audit_logs: audit_logs)
45 else
46 0 not_found(conn)
47 end
48 end
49
50 8 defp sort(nil), do: sort("name")
51 0 defp sort("downloads"), do: sort("total_downloads")
52 10 defp sort(param), do: Hexpm.Utils.safe_to_atom(param, @sort_params)
53
54 defp repositories(conn) do
55 10 cond do
56 10 repository = conn.assigns.repository ->
57 [repository]
58
59 8 user = conn.assigns.current_user ->
60 1 Enum.map(Users.all_organizations(user), & &1.repository)
61
62 7 organization = conn.assigns.current_organization ->
63 0 [Repository.hexpm(), organization.repository]
64
65 7 true ->
66 [Repository.hexpm()]
67 end
68 end
69 end

lib/hexpm_web/controllers/api/release_controller.ex

92.7
55
894
4
Line Hits Source
0 defmodule HexpmWeb.API.ReleaseController do
1 use HexpmWeb, :controller
2
3 plug :parse_tarball when action in [:publish]
4 plug :maybe_fetch_release when action in [:show]
5 plug :fetch_release when action in [:delete]
6 plug :maybe_fetch_package when action in [:create, :publish]
7
8 plug :authorize,
9 [domain: "api", resource: "read", fun: &organization_access/2]
10 when action in [:show]
11
12 plug :authorize,
13 [
14 domain: "api",
15 resource: "write",
16 fun: [&package_owner/2, &organization_billing_active/2]
17 ]
18 when action in [:create, :publish]
19
20 plug :authorize,
21 [
22 domain: "api",
23 resource: "write",
24 fun: [&package_owner/2, &organization_billing_active/2]
25 ]
26 when action in [:delete]
27
28 @download_period_params ~w(day month all)
29
30 def publish(conn, %{"body" => body} = params) do
31 28 replace? = Map.get(params, "replace", true)
32 28 request_id = List.first(get_resp_header(conn, "x-request-id"))
33
34 28 log_tarball(
35 28 conn.assigns.repository.name,
36 28 conn.assigns.meta["name"],
37 28 conn.assigns.meta["version"],
38 request_id,
39 body
40 )
41
42 Releases.publish(
43 28 conn.assigns.repository,
44 28 conn.assigns.package,
45 28 conn.assigns.current_user,
46 body,
47 28 conn.assigns.meta,
48 28 conn.assigns.inner_checksum,
49 28 conn.assigns.outer_checksum,
50 audit: audit_data(conn),
51 replace: replace?
52 )
53 28 |> publish_result(conn)
54 end
55
56 def create(conn, %{"body" => body}) do
57 8 handle_tarball(
58 conn,
59 8 conn.assigns.repository,
60 8 conn.assigns.package,
61 8 conn.assigns.current_user,
62 body
63 )
64 end
65
66 def show(conn, params) do
67 11 if release = conn.assigns.release do
68 8 downloads_period = Hexpm.Utils.safe_to_atom(params["downloads"], @download_period_params)
69 8 downloads = Releases.downloads_by_period(release.id, downloads_period)
70
71 8 release =
72 release
73 |> Releases.preload([:requirements, :publisher])
74 |> Map.put(:downloads, downloads)
75
76 8 when_stale(conn, release, fn conn ->
77 conn
78 |> api_cache(:public)
79 8 |> render(:show, release: release)
80 end)
81 else
82 3 not_found(conn)
83 end
84 end
85
86 def delete(conn, _params) do
87 7 package = conn.assigns.package
88 7 release = conn.assigns.release
89
90 7 case Releases.revert(package, release, audit: audit_data(conn)) do
91 :ok ->
92 conn
93 |> api_cache(:private)
94 5 |> send_resp(204, "")
95
96 {:error, _, changeset, _} ->
97 2 validation_failed(conn, changeset)
98 end
99 end
100
101 defp parse_tarball(conn, _opts) do
102 36 case release_metadata(conn.params["body"]) do
103 {:ok, meta, inner_checksum, outer_checksum} ->
104 36 params = Map.put(conn.params, "name", meta["name"])
105
106 %{conn | params: params}
107 |> assign(:meta, meta)
108 |> assign(:inner_checksum, inner_checksum)
109 36 |> assign(:outer_checksum, outer_checksum)
110
111 {:error, errors} ->
112 0 validation_failed(conn, %{tar: errors})
113 end
114 end
115
116 defp handle_tarball(conn, repository, package, user, body) do
117 case release_metadata(body) do
118 {:ok, meta, inner_checksum, outer_checksum} ->
119 8 replace? = Map.get(conn.params, "replace", true)
120 8 request_id = List.first(get_resp_header(conn, "x-request-id"))
121 8 log_tarball(repository.name, meta["name"], meta["version"], request_id, body)
122
123 8 Releases.publish(
124 repository,
125 package,
126 user,
127 body,
128 meta,
129 inner_checksum,
130 outer_checksum,
131 audit: audit_data(conn),
132 replace: replace?
133 )
134
135 0 {:error, errors} ->
136 {:error, %{tar: errors}}
137 end
138 8 |> publish_result(conn)
139 end
140
141 defp publish_result({:ok, %{action: :insert, package: package, release: release}}, conn) do
142 19 location = Routes.api_release_url(conn, :show, package, release)
143
144 conn
145 |> put_resp_header("location", location)
146 |> api_cache(:public)
147 |> put_status(201)
148 19 |> render(:show, release: release)
149 end
150
151 defp publish_result({:ok, %{action: :update, release: release}}, conn) do
152 conn
153 |> api_cache(:public)
154 6 |> render(:show, release: release)
155 end
156
157 defp publish_result({:error, errors}, conn) do
158 0 validation_failed(conn, errors)
159 end
160
161 defp publish_result({:error, _, changeset, _}, conn) do
162 11 validation_failed(conn, normalize_errors(changeset))
163 end
164
165 defp normalize_errors(%{changes: %{requirements: requirements}} = changeset) do
166 2 requirements =
167 Enum.map(requirements, fn %{errors: errors} = req ->
168 2 name = Ecto.Changeset.get_field(req, :name)
169 2 %{req | errors: for({_, v} <- errors, do: {name, v}, into: %{})}
170 end)
171
172 2 put_in(changeset.changes.requirements, requirements)
173 end
174
175 9 defp normalize_errors(changeset), do: changeset
176
177 defp log_tarball(repository, package, version, request_id, body) do
178 36 filename = "#{repository}-#{package}-#{version}-#{request_id}.tar.gz"
179 36 key = Path.join(["debug", "tarballs", filename])
180 36 Hexpm.Store.put(:repo_bucket, key, body, [])
181 end
182
183 defp release_metadata(tarball) do
184 44 case :hex_tarball.unpack(tarball, :memory) do
185 {:ok, %{inner_checksum: inner_checksum, outer_checksum: outer_checksum, metadata: metadata}} ->
186 44 {:ok, metadata, inner_checksum, outer_checksum}
187
188 0 {:error, reason} ->
189 {:error, List.to_string(:hex_tarball.format_error(reason))}
190 end
191 end
192 end

lib/hexpm_web/controllers/api/repository_controller.ex

92.9
14
22
1
Line Hits Source
0 defmodule HexpmWeb.API.RepositoryController do
1 use HexpmWeb, :controller
2
3 plug :fetch_repository when action in [:show]
4 plug :authorize, [domain: "api", resource: "read"] when action in [:index]
5
6 plug :authorize,
7 [domain: "api", resource: "read", fun: &organization_access/2]
8 when action in [:show]
9
10 def index(conn, _params) do
11 2 repositories =
12 Repositories.all_public() ++
13 2 all_by_user(conn.assigns.current_user) ++
14 2 all_by_organization(conn.assigns.current_organization)
15
16 2 when_stale(conn, repositories, [modified: false], fn conn ->
17 conn
18 |> api_cache(:logged_in)
19 2 |> render(:index, repositories: repositories)
20 end)
21 end
22
23 def show(conn, _params) do
24 2 repository = conn.assigns.repository
25
26 2 when_stale(conn, repository, fn conn ->
27 conn
28 |> api_cache(show_cachability(repository))
29 2 |> render(:show, repository: repository)
30 end)
31 end
32
33 1 defp all_by_user(nil) do
34 []
35 end
36
37 defp all_by_user(user) do
38 1 Enum.map(Organizations.all_by_user(user, [:repository]), & &1.repository)
39 end
40
41 2 defp all_by_organization(nil), do: []
42 0 defp all_by_organization(organization), do: [organization.repository]
43
44 1 defp show_cachability(%Repository{id: 1}), do: :public
45 1 defp show_cachability(%Repository{}), do: :private
46 end

lib/hexpm_web/controllers/api/retirement_controller.ex

90.9
11
30
1
Line Hits Source
0 defmodule HexpmWeb.API.RetirementController do
1 use HexpmWeb, :controller
2
3 plug :maybe_fetch_release when action in [:create, :delete]
4
5 plug :authorize,
6 [domain: "api", resource: "write", fun: &package_owner/2]
7 when action in [:create, :delete]
8
9 def create(conn, params) do
10 4 package = conn.assigns.package
11
12 4 if release = conn.assigns.release do
13 3 case Releases.retire(package, release, params, audit: audit_data(conn)) do
14 :ok ->
15 conn
16 |> api_cache(:private)
17 3 |> send_resp(204, "")
18
19 {:error, _, changeset, _} ->
20 0 validation_failed(conn, changeset)
21 end
22 else
23 1 not_found(conn)
24 end
25 end
26
27 def delete(conn, _params) do
28 4 package = conn.assigns.package
29
30 4 if release = conn.assigns.release do
31 3 Releases.unretire(package, release, audit: audit_data(conn))
32
33 conn
34 |> api_cache(:private)
35 3 |> send_resp(204, "")
36 else
37 1 not_found(conn)
38 end
39 end
40 end

lib/hexpm_web/controllers/api/short_url_controller.ex

100
3
4
0
Line Hits Source
0 defmodule HexpmWeb.API.ShortURLController do
1 use HexpmWeb, :controller
2 alias Hexpm.ShortURLs
3
4 def create(conn, params) do
5 2 case ShortURLs.add(params) do
6 {:ok, short_url} ->
7 conn
8 |> put_status(201)
9 1 |> render(:show, url: Routes.short_url_url(conn, :show, short_url.short_code))
10
11 {:error, changeset} ->
12 1 validation_failed(conn, changeset)
13 end
14 end
15 end

lib/hexpm_web/controllers/api/user_controller.ex

100
26
73
0
Line Hits Source
0 defmodule HexpmWeb.API.UserController do
1 use HexpmWeb, :controller
2
3 plug :authorize,
4 [authentication: :required, domain: "api", resource: "read"]
5 when action in [:test, :me, :audit_logs]
6
7 def create(conn, params) do
8 4 params = email_param(params)
9
10 4 case Users.add(params, audit: audit_data(conn)) do
11 {:ok, user} ->
12 3 location = Routes.api_user_url(conn, :show, user.username)
13
14 conn
15 |> put_resp_header("location", location)
16 |> api_cache(:private)
17 |> put_status(201)
18 3 |> render(:show, user: user)
19
20 {:error, changeset} ->
21 1 validation_failed(conn, changeset)
22 end
23 end
24
25 def me(conn, _params) do
26 2 if user = conn.assigns.current_user do
27 1 when_stale(conn, user, fn conn ->
28 conn
29 |> api_cache(:private)
30 1 |> render(:me, user: user)
31 end)
32 else
33 1 not_found(conn)
34 end
35 end
36
37 def audit_logs(conn, params) do
38 2 if user = conn.assigns.current_user do
39 1 audit_logs = AuditLogs.all_by(user, Hexpm.Utils.safe_int(params["page"]), 100)
40
41 1 render(conn, :audit_logs, audit_logs: audit_logs)
42 else
43 1 not_found(conn)
44 end
45 end
46
47 def show(conn, %{"name" => name}) do
48 6 user = Users.public_get(name, [:emails, owned_packages: :repository])
49 6 accessible_packages = Packages.accessible_user_owned_packages(user, conn.assigns.current_user)
50
51 6 user = user && %{user | owned_packages: accessible_packages}
52
53 6 if user do
54 5 when_stale(conn, user, fn conn ->
55 conn
56 |> api_cache(:private)
57 5 |> render(:show, user: user)
58 end)
59 else
60 1 not_found(conn)
61 end
62 end
63
64 def test(conn, params) do
65 1 show(conn, params)
66 end
67
68 def reset(conn, %{"name" => name}) do
69 2 Users.password_reset_init(name, audit: audit_data(conn))
70
71 conn
72 |> api_cache(:private)
73 2 |> send_resp(204, "")
74 end
75
76 defp email_param(params) do
77 4 if email = params["email"] do
78 3 Map.put_new(params, "emails", [%{"email" => email}])
79 else
80 1 params
81 end
82 end
83 end

lib/hexpm_web/controllers/blog_controller.ex

0
9
0
9
Line Hits Source
0 defmodule HexpmWeb.BlogController do
1 use HexpmWeb, :controller
2
3 Enum.each(HexpmWeb.BlogView.all_templates(), fn {slug, template} ->
4 0 defp slug_to_template(unquote(slug)), do: unquote(Path.rootname(template))
5 end)
6
7 0 defp slug_to_template(_other), do: nil
8
9 def index(conn, _params) do
10 0 render(
11 conn,
12 "index.html",
13 title: "Blog",
14 container: "container page page-sm blog"
15 )
16 end
17
18 def show(conn, %{"slug" => "002-organizations-going-live"}) do
19 0 redirect(conn, to: Routes.blog_path(Endpoint, :show, "organizations-going-live"))
20 end
21
22 def show(conn, %{"slug" => slug}) do
23 0 if template = slug_to_template(slug) do
24 0 render(
25 conn,
26 0 "#{template}.html",
27 title: title(slug),
28 container: "container page page-sm blog"
29 )
30 else
31 0 not_found(conn)
32 end
33 end
34
35 defp title(slug) do
36 slug
37 |> String.replace("-", " ")
38 0 |> String.capitalize()
39 end
40 end

lib/hexpm_web/controllers/controller_helpers.ex

77.9
136
4922
30
Line Hits Source
0 defmodule HexpmWeb.ControllerHelpers do
1 import Plug.Conn
2 import Phoenix.Controller
3
4 alias Hexpm.Accounts.{Auth, Organizations}
5 alias Hexpm.Repository.{Packages, Releases, Repositories}
6 alias HexpmWeb.Router.Helpers, as: Routes
7
8 @max_cache_age 60
9
10 # TODO: check privacy settings
11 def cache(conn, control, vary) do
12 conn
13 |> maybe_put_resp_header("cache-control", parse_control(control))
14 129 |> maybe_put_resp_header("vary", parse_vary(vary))
15 end
16
17 def api_cache(conn, privacy) do
18 120 control = [logged_in_privacy(conn, privacy), "max-age": @max_cache_age]
19 120 vary = ["accept", "accept-encoding"]
20 120 cache(conn, control, vary)
21 end
22
23 defp logged_in_privacy(conn, :logged_in) do
24 2 if conn.assigns.current_user, do: :private, else: :public
25 end
26
27 defp logged_in_privacy(_conn, other) do
28 118 other
29 end
30
31 0 defp parse_vary(nil), do: nil
32 129 defp parse_vary(vary), do: Enum.map_join(vary, ", ", &"#{&1}")
33
34 0 defp parse_control(nil), do: nil
35
36 defp parse_control(control) do
37 129 Enum.map_join(control, ", ", fn
38 129 atom when is_atom(atom) -> "#{atom}"
39 129 {key, value} -> "#{key}=#{value}"
40 end)
41 end
42
43 0 defp maybe_put_resp_header(conn, _header, nil), do: conn
44 258 defp maybe_put_resp_header(conn, header, value), do: put_resp_header(conn, header, value)
45
46 def render_error(conn, status, assigns \\ []) do
47 conn
48 |> put_status(status)
49 |> put_layout(false)
50 |> put_view(HexpmWeb.ErrorView)
51 196 |> render(:"#{status}", assigns)
52 196 |> halt()
53 end
54
55 def validation_failed(conn, %Ecto.Changeset{} = changeset) do
56 17 errors = translate_errors(changeset)
57 17 render_error(conn, 422, errors: errors)
58 end
59
60 def validation_failed(conn, errors) do
61 8 render_error(conn, 422, errors: errors)
62 end
63
64 def translate_errors(changeset) do
65 Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
66 51 case {message, Keyword.fetch(opts, :type)} do
67 1 {"is invalid", {:ok, type}} -> type_error(type)
68 50 _ -> interpolate_errors(message, opts)
69 end
70 end)
71 40 |> normalize_errors()
72 end
73
74 defp interpolate_errors(message, opts) do
75 50 Enum.reduce(opts, message, fn {key, value}, message ->
76 51 pattern = "%{#{key}}"
77
78 51 if String.contains?(message, pattern) do
79 3 if String.Chars.impl_for(value) do
80 3 String.replace(message, pattern, to_string(value))
81 else
82 0 raise "Unable to translate error: #{inspect({message, opts})}"
83 end
84 else
85 48 message
86 end
87 end)
88 end
89
90 1 defp type_error(type), do: "expected type #{pretty_type(type)}"
91
92 0 defp pretty_type({:array, type}), do: "list(#{pretty_type(type)})"
93 1 defp pretty_type({:map, type}), do: "map(#{pretty_type(type)})"
94 1 defp pretty_type(type), do: type |> inspect() |> String.trim_leading(":")
95
96 # Since Changeset.traverse_errors returns `{field: [err], ...}`
97 # but Hex client expects `{field: err1, ...}` we normalize to the latter.
98 defp normalize_errors(errors) do
99 Enum.flat_map(errors, &normalize_key_value/1)
100 57 |> Map.new()
101 end
102
103 defp normalize_key_value({key, value}) do
104 68 case value do
105 0 _ when value == %{} ->
106 []
107
108 [%{} | _] = value ->
109 11 value = Enum.reduce(value, %{}, &Map.merge(&2, normalize_errors(&1)))
110 [{key, value}]
111
112 0 [] ->
113 []
114
115 6 value when is_map(value) ->
116 [{key, normalize_errors(value)}]
117
118 51 [value | _] ->
119 [{key, value}]
120 end
121 end
122
123 def not_found(conn) do
124 114 render_error(conn, 404)
125 end
126
127 def when_stale(conn, entities, opts \\ [], fun) do
128 32 etag = etag(entities)
129 32 modified = if Keyword.get(opts, :modified, true), do: last_modified(entities)
130
131 32 conn =
132 conn
133 |> put_etag(etag)
134 |> put_last_modified(modified)
135
136 32 if fresh?(conn, etag: etag, modified: modified) do
137 0 send_resp(conn, 304, "")
138 else
139 32 fun.(conn)
140 end
141 end
142
143 defp put_etag(conn, nil) do
144 0 conn
145 end
146
147 defp put_etag(conn, etag) do
148 32 put_resp_header(conn, "etag", etag)
149 end
150
151 defp put_last_modified(conn, nil) do
152 12 conn
153 end
154
155 defp put_last_modified(conn, modified) do
156 20 put_resp_header(conn, "last-modified", :cowboy_clock.rfc1123(modified))
157 end
158
159 defp fresh?(conn, opts) do
160 32 not expired?(conn, opts)
161 end
162
163 defp expired?(conn, opts) do
164 32 modified_since = List.first(get_req_header(conn, "if-modified-since"))
165 32 none_match = List.first(get_req_header(conn, "if-none-match"))
166
167 32 if modified_since || none_match do
168 0 modified_since?(modified_since, opts[:modified]) or none_match?(none_match, opts[:etag])
169 else
170 true
171 end
172 end
173
174 defp modified_since?(header, last_modified) do
175 0 if header && last_modified do
176 0 modified_since = :httpd_util.convert_request_date(String.to_charlist(header))
177 0 modified_since = :calendar.datetime_to_gregorian_seconds(modified_since)
178 0 last_modified = :calendar.datetime_to_gregorian_seconds(last_modified)
179 0 last_modified > modified_since
180 else
181 false
182 end
183 end
184
185 defp none_match?(none_match, etag) do
186 0 if none_match && etag do
187 0 none_match = Plug.Conn.Utils.list(none_match)
188 0 etag not in none_match and "*" not in none_match
189 else
190 false
191 end
192 end
193
194 defp etag(schemas) do
195 32 binary =
196 schemas
197 |> List.wrap()
198 |> Enum.map(&HexpmWeb.Stale.etag/1)
199 |> List.flatten()
200 |> :erlang.term_to_binary()
201
202 :crypto.hash(:md5, binary)
203 32 |> Base.encode16(case: :lower)
204 end
205
206 def last_modified(schemas) do
207 schemas
208 |> List.wrap()
209 |> Enum.map(&HexpmWeb.Stale.last_modified/1)
210 |> List.flatten()
211 47 |> Enum.reject(&is_nil/1)
212 |> Enum.map(&time_to_erl/1)
213 20 |> Enum.max()
214 end
215
216 1 defp time_to_erl(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_erl(datetime)
217 34 defp time_to_erl(%DateTime{} = datetime), do: NaiveDateTime.to_erl(datetime)
218 9 defp time_to_erl(%Date{} = date), do: {Date.to_erl(date), {0, 0, 0}}
219
220 def fetch_repository(conn, _opts) do
221 16 if param = conn.params["repository"] do
222 8 if repository = Repositories.get(param, [:organization]) do
223 conn
224 |> assign(:repository, repository)
225 8 |> assign(:organization, repository.organization)
226 else
227 conn
228 |> not_found()
229 0 |> halt()
230 end
231 else
232 conn
233 |> assign(:repository, nil)
234 8 |> assign(:organization, nil)
235 end
236 end
237
238 def fetch_organization(conn, _opts) do
239 56 if param = conn.params["organization"] do
240 41 if organization = Organizations.get(param) do
241 39 assign(conn, :organization, organization)
242 else
243 conn
244 |> not_found()
245 2 |> halt()
246 end
247 else
248 15 assign(conn, :organization, nil)
249 end
250 end
251
252 def maybe_fetch_package(conn, _opts) do
253 115 repository = Repositories.get(conn.params["repository"], [:organization])
254 115 package = repository && Packages.get(repository, conn.params["name"])
255
256 conn
257 |> assign(:repository, repository)
258 |> assign(:package, package)
259 115 |> assign(:organization, repository && repository.organization)
260 end
261
262 def fetch_release(conn, _opts) do
263 27 case Version.parse(conn.params["version"]) do
264 {:ok, version} ->
265 27 repository = Repositories.get(conn.params["repository"], [:organization])
266 27 package = repository && Packages.get(repository, conn.params["name"])
267 27 release = package && Releases.get(package, version)
268
269 27 if release do
270 conn
271 |> assign(:repository, repository)
272 |> assign(:package, package)
273 |> assign(:release, release)
274 26 |> assign(:organization, repository && repository.organization)
275 else
276 conn
277 |> not_found()
278 1 |> halt()
279 end
280
281 :error ->
282 0 render_error(conn, 400, message: "invalid version: #{conn.params["version"]}")
283 end
284 end
285
286 def maybe_fetch_release(conn, _opts) do
287 33 case Version.parse(conn.params["version"]) do
288 {:ok, version} ->
289 32 repository = Repositories.get(conn.params["repository"], [:organization])
290 32 package = repository && Packages.get(repository, conn.params["name"])
291 32 release = package && Releases.get(package, version)
292
293 conn
294 |> assign(:repository, repository)
295 |> assign(:package, package)
296 |> assign(:release, release)
297 32 |> assign(:organization, repository && repository.organization)
298
299 :error ->
300 1 render_error(conn, 400, message: "invalid version: #{conn.params["version"]}")
301 end
302 end
303
304 def required_params(conn, required_param_names) do
305 40 remaining = required_param_names -- Map.keys(conn.params)
306
307 40 if remaining == [] do
308 39 conn
309 else
310 1 names = Enum.map_join(remaining, ", ", &inspect/1)
311 1 message = "missing required parameters: #{names}"
312 1 render_error(conn, 400, message: message)
313 end
314 end
315
316 def audit_data(conn) do
317 163 user_or_organization = conn.assigns.current_user || conn.assigns.current_organization
318 163 {user_or_organization, conn.assigns.user_agent, ip_to_string(conn.remote_ip)}
319 end
320
321 0 defp ip_to_string(nil), do: nil
322
323 defp ip_to_string(tuple) when is_tuple(tuple) and tuple_size(tuple) == 4,
324 163 do: tuple |> Tuple.to_list() |> Enum.join(".")
325
326 defp ip_to_string(tuple) when is_tuple(tuple) and tuple_size(tuple) == 8,
327 0 do: tuple |> Tuple.to_list() |> Enum.map(&String.to_integer(&1, 16)) |> Enum.join(":")
328
329 def password_auth(username, password) do
330 12 case Auth.password_auth(username, password) do
331 {:ok, %{user: user, email: email}} ->
332 11 if email.verified,
333 do: {:ok, user},
334 else: {:error, :unconfirmed}
335
336 1 :error ->
337 {:error, :wrong}
338 end
339 end
340
341 1 def auth_error_message(:wrong), do: "Invalid username, email or password."
342
343 1 def auth_error_message(:unconfirmed),
344 do: "Email has not been verified yet. You can resend the verification email below."
345
346 def password_breached_message(conn, _opts) do
347 # docs_path + anchor #password-security
348 0 "The password you provided has previously been breached. " <>
349 "To increase your security, please change your password." <>
350 0 "<br /><a class=\"small\" href=\"#{Routes.docs_path(conn, :faq)}#password-security\">" <>
351 "Learn more about our password security.</a>"
352 end
353
354 def requires_login(conn, _opts) do
355 113 if logged_in?(conn) do
356 105 conn
357 else
358 8 redirect(conn, to: Routes.login_path(conn, :show, return: conn.request_path))
359 8 |> halt
360 end
361 end
362
363 def logged_in?(conn) do
364 115 !!conn.assigns[:current_user]
365 end
366
367 def nillify_params(conn, keys) do
368 14 params =
369 14 Enum.reduce(keys, conn.params, fn key, params ->
370 14 case Map.fetch(conn.params, key) do
371 1 {:ok, value} -> Map.put(params, key, scrub_param(value))
372 13 :error -> params
373 end
374 end)
375
376 14 %{conn | params: params}
377 end
378
379 defp scrub_param(%{__struct__: mod} = struct) when is_atom(mod) do
380 0 struct
381 end
382
383 defp scrub_param(%{} = param) do
384 0 Enum.reduce(param, %{}, fn {k, v}, acc ->
385 0 Map.put(acc, k, scrub_param(v))
386 end)
387 end
388
389 defp scrub_param(param) when is_list(param) do
390 0 Enum.map(param, &scrub_param/1)
391 end
392
393 defp scrub_param(param) do
394 1 if scrub?(param), do: nil, else: param
395 end
396
397 0 defp scrub?(" " <> rest), do: scrub?(rest)
398 0 defp scrub?(""), do: true
399 1 defp scrub?(_), do: false
400 end

lib/hexpm_web/controllers/dashboard/audit_log_controller.ex

100
4
12
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.AuditLogController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 @per_page 100
6
7 def index(conn, params) do
8 3 page = Hexpm.Utils.safe_int(params["page"]) || 1
9 3 audit_logs = Hexpm.Accounts.AuditLogs.all_by(conn.assigns.current_user, page, @per_page)
10 3 count = Hexpm.Accounts.AuditLogs.count_by(conn.assigns.current_user)
11
12 conn
13 3 |> render(
14 "index.html",
15 title: "Dashboard - Recent activities",
16 container: "container page dashboard",
17 audit_logs: audit_logs,
18 page: page,
19 per_page: @per_page,
20 total_count: count
21 )
22 end
23 end

lib/hexpm_web/controllers/dashboard/email_controller.ex

91.2
34
52
3
Line Hits Source
0 defmodule HexpmWeb.Dashboard.EmailController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 render_index(conn, conn.assigns.current_user)
7 end
8
9 def create(conn, %{"email" => email_params}) do
10 3 user = conn.assigns.current_user
11
12 3 case Users.add_email(user, email_params, audit: audit_data(conn)) do
13 {:ok, _user} ->
14 2 email = email_params["email"]
15
16 conn
17 2 |> put_flash(:info, "A verification email has been sent to #{email}.")
18 2 |> redirect(to: Routes.email_path(conn, :index))
19
20 {:error, changeset} ->
21 conn
22 |> put_status(400)
23 1 |> render_index(user, changeset)
24 end
25 end
26
27 def delete(conn, %{"email" => email} = params) do
28 2 case Users.remove_email(conn.assigns.current_user, params, audit: audit_data(conn)) do
29 :ok ->
30 conn
31 1 |> put_flash(:info, "Removed email #{email} from your account.")
32 1 |> redirect(to: Routes.email_path(conn, :index))
33
34 {:error, reason} ->
35 conn
36 |> put_flash(:error, email_error_message(reason, email))
37 1 |> redirect(to: Routes.email_path(conn, :index))
38 end
39 end
40
41 def primary(conn, %{"email" => email} = params) do
42 3 case Users.primary_email(conn.assigns.current_user, params, audit: audit_data(conn)) do
43 :ok ->
44 conn
45 2 |> put_flash(:info, "Your primary email was changed to #{email}.")
46 2 |> redirect(to: Routes.email_path(conn, :index))
47
48 {:error, reason} ->
49 conn
50 |> put_flash(:error, email_error_message(reason, email))
51 1 |> redirect(to: Routes.email_path(conn, :index))
52 end
53 end
54
55 def public(conn, %{"email" => email} = params) do
56 2 case Users.public_email(conn.assigns.current_user, params, audit: audit_data(conn)) do
57 :ok ->
58 conn
59 2 |> put_flash(:info, "Your public email was changed to #{email}.")
60 2 |> redirect(to: Routes.email_path(conn, :index))
61
62 {:error, reason} ->
63 conn
64 |> put_flash(:error, email_error_message(reason, email))
65 0 |> redirect(to: Routes.email_path(conn, :index))
66 end
67 end
68
69 def gravatar(conn, %{"email" => email} = params) do
70 3 case Users.gravatar_email(conn.assigns.current_user, params, audit: audit_data(conn)) do
71 :ok ->
72 conn
73 1 |> put_flash(:info, "Your gravatar email was changed to #{email}.")
74 1 |> redirect(to: Routes.email_path(conn, :index))
75
76 {:error, reason} ->
77 conn
78 |> put_flash(:error, email_error_message(reason, email))
79 2 |> redirect(to: Routes.email_path(conn, :index))
80 end
81 end
82
83 def resend_verify(conn, %{"email" => email} = params) do
84 1 case Users.resend_verify_email(conn.assigns.current_user, params) do
85 :ok ->
86 conn
87 1 |> put_flash(:info, "A verification email has been sent to #{email}.")
88 1 |> redirect(to: Routes.email_path(conn, :index))
89
90 {:error, reason} ->
91 conn
92 |> put_flash(:error, email_error_message(reason, email))
93 0 |> redirect(to: Routes.email_path(conn, :index))
94 end
95 end
96
97 defp render_index(conn, user, create_changeset \\ create_changeset()) do
98 2 emails = Email.order_emails(user.emails)
99
100 2 render(
101 conn,
102 "index.html",
103 title: "Dashboard - Email",
104 container: "container page dashboard",
105 create_changeset: create_changeset,
106 emails: emails
107 )
108 end
109
110 defp create_changeset() do
111 1 Email.changeset(%Email{}, :create, %{}, false)
112 end
113
114 1 defp email_error_message(:unknown_email, email), do: "Unknown email #{email}."
115 2 defp email_error_message(:not_verified, email), do: "Email #{email} not verified."
116 0 defp email_error_message(:already_verified, email), do: "Email #{email} already verified."
117 1 defp email_error_message(:primary, email), do: "Cannot remove primary email #{email}."
118 end

lib/hexpm_web/controllers/dashboard/key_controller.ex

78.8
33
40
7
Line Hits Source
0 defmodule HexpmWeb.Dashboard.KeyController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 render_index(conn)
7 end
8
9 def delete(conn, %{"name" => name}) do
10 2 user = conn.assigns.current_user
11
12 2 case Keys.revoke(user, name, audit: audit_data(conn)) do
13 {:ok, _struct} ->
14 conn
15 1 |> put_flash(:info, "The key #{name} was revoked successfully.")
16 1 |> redirect(to: Routes.key_path(conn, :index))
17
18 {:error, _} ->
19 conn
20 |> put_status(400)
21 1 |> put_flash(:error, "The key #{name} was not found.")
22 1 |> render_index()
23 end
24 end
25
26 def create(conn, params) do
27 1 user = conn.assigns.current_user
28 1 key_params = munge_permissions(params["key"])
29
30 1 case Keys.create(user, key_params, audit: audit_data(conn)) do
31 {:ok, %{key: key}} ->
32 1 flash =
33 1 "The key #{key.name} was successfully generated, " <>
34 1 "copy the secret \"#{key.user_secret}\", you won't be able to see it again."
35
36 conn
37 |> put_flash(:info, flash)
38 1 |> redirect(to: Routes.key_path(conn, :index))
39
40 {:error, :key, changeset, _} ->
41 conn
42 |> put_status(400)
43 0 |> render_index(changeset)
44 end
45 end
46
47 defp render_index(conn, changeset \\ changeset()) do
48 2 user = conn.assigns.current_user
49 2 keys = Keys.all(user)
50 2 organizations = Organizations.all_by_user(user)
51
52 2 render(
53 conn,
54 "index.html",
55 title: "Dashboard - User keys",
56 container: "container page dashboard",
57 keys: keys,
58 organizations: organizations,
59 delete_key_path: Routes.key_path(Endpoint, :delete),
60 create_key_path: Routes.key_path(Endpoint, :create),
61 key_changeset: changeset
62 )
63 end
64
65 defp changeset() do
66 2 Key.changeset(%Key{}, %{}, %{})
67 end
68
69 def munge_permissions(params) do
70 2 permissions = params["permissions"] || []
71
72 2 permissions =
73 if {"repositories", "on"} in permissions do
74 0 Enum.reject(permissions, &match?({"repository", _}, &1))
75 else
76 2 permissions
77 end
78
79 2 permissions =
80 if {"apis", "on"} in permissions do
81 0 Enum.reject(permissions, &match?({"api", _}, &1))
82 else
83 2 permissions
84 end
85
86 2 permissions =
87 Enum.flat_map(permissions, fn
88 0 {"repositories", "on"} ->
89 [%{"domain" => "repositories", "resource" => nil}]
90
91 0 {"apis", "on"} ->
92 [%{"domain" => "api", "resource" => nil}]
93
94 {"api", resources} ->
95 0 Enum.map(Map.keys(resources), &%{"domain" => "api", "resource" => &1})
96
97 {"repository", resources} ->
98 0 Enum.map(Map.keys(resources), &%{"domain" => "repository", "resource" => &1})
99 end)
100
101 2 put_in(params["permissions"], permissions)
102 end
103 end

lib/hexpm_web/controllers/dashboard/organization_controller.ex

86.7
173
667
23
Line Hits Source
0 defmodule HexpmWeb.Dashboard.OrganizationController do
1 use HexpmWeb, :controller
2 alias HexpmWeb.Dashboard.KeyController
3
4 plug :requires_login
5
6 def redirect_repo(conn, params) do
7 0 glob = params["glob"] || []
8 0 path = Routes.organization_path(conn, :new) <> "/" <> Enum.join(glob, "/")
9
10 conn
11 |> put_status(301)
12 0 |> redirect(to: path)
13 end
14
15 def show(conn, %{"dashboard_org" => organization}) do
16 5 access_organization(conn, organization, "read", fn organization ->
17 4 render_index(conn, organization)
18 end)
19 end
20
21 def update(conn, %{
22 "dashboard_org" => organization,
23 "action" => "add_member",
24 "organization_user" => params
25 }) do
26 2 username = params["username"]
27
28 2 access_organization(conn, organization, "admin", fn organization ->
29 2 user_count = Organizations.user_count(organization)
30 2 customer = Hexpm.Billing.get(organization.name)
31
32 2 if !customer["subscription"] || customer["quantity"] > user_count do
33 1 if user = Users.public_get(username, [:emails]) do
34 1 case Organizations.add_member(organization, user, params, audit: audit_data(conn)) do
35 {:ok, _} ->
36 conn
37 1 |> put_flash(:info, "User #{username} has been added to the organization.")
38 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
39
40 {:error, changeset} ->
41 conn
42 |> put_status(400)
43 0 |> render_index(organization, add_member: changeset)
44 end
45 else
46 conn
47 |> put_status(400)
48 0 |> put_flash(:error, "Unknown user #{username}.")
49 0 |> render_index(organization)
50 end
51 else
52 conn
53 |> put_status(400)
54 |> put_flash(:error, "Not enough seats in organization to add member.")
55 1 |> render_index(organization)
56 end
57 end)
58 end
59
60 def update(conn, %{
61 "dashboard_org" => organization,
62 "action" => "remove_member",
63 "organization_user" => params
64 }) do
65 # TODO: Also remove all package ownerships on organization for removed member
66 1 username = params["username"]
67
68 1 access_organization(conn, organization, "admin", fn organization ->
69 1 user = Users.public_get(username)
70
71 1 case Organizations.remove_member(organization, user, audit: audit_data(conn)) do
72 :ok ->
73 conn
74 1 |> put_flash(:info, "User #{username} has been removed from the organization.")
75 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
76
77 {:error, :last_member} ->
78 conn
79 |> put_status(400)
80 |> put_flash(:error, "Cannot remove last member from organization.")
81 0 |> render_index(organization)
82 end
83 end)
84 end
85
86 def update(conn, %{
87 "dashboard_org" => organization,
88 "action" => "change_role",
89 "organization_user" => params
90 }) do
91 1 username = params["username"]
92
93 1 access_organization(conn, organization, "admin", fn organization ->
94 1 if user = Users.public_get(username) do
95 1 case Organizations.change_role(organization, user, params, audit: audit_data(conn)) do
96 {:ok, _} ->
97 conn
98 1 |> put_flash(:info, "User #{username}'s role has been changed to #{params["role"]}.")
99 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
100
101 {:error, :last_admin} ->
102 conn
103 |> put_status(400)
104 |> put_flash(:error, "Cannot demote last admin member.")
105 0 |> render_index(organization)
106
107 {:error, changeset} ->
108 conn
109 |> put_status(400)
110 0 |> render_index(organization, change_role: changeset)
111 end
112 else
113 conn
114 |> put_status(400)
115 0 |> put_flash(:error, "Unknown user #{username}.")
116 0 |> render_index(organization)
117 end
118 end)
119 end
120
121 def leave(conn, %{
122 "dashboard_org" => organization,
123 "organization_name" => organization_name
124 }) do
125 1 access_organization(conn, organization, "read", fn organization ->
126 1 if organization.name == organization_name do
127 1 current_user = conn.assigns.current_user
128
129 1 case Organizations.remove_member(organization, current_user, audit: audit_data(conn)) do
130 :ok ->
131 conn
132 1 |> put_flash(:info, "You just left the the organization #{organization.name}.")
133 1 |> redirect(to: Routes.profile_path(conn, :index))
134
135 {:error, :last_member} ->
136 conn
137 |> put_status(400)
138 |> put_flash(:error, "The last member of an organization cannot leave.")
139 0 |> render_index(organization)
140 end
141 else
142 conn
143 |> put_status(400)
144 |> put_flash(:error, "Invalid organization name.")
145 0 |> render_index(organization)
146 end
147 end)
148 end
149
150 def billing_token(conn, %{"dashboard_org" => organization, "token" => token}) do
151 2 access_organization(conn, organization, "admin", fn organization ->
152 2 audit = %{audit_data: audit_data(conn), organization: organization}
153
154 2 case Hexpm.Billing.checkout(organization.name, %{payment_source: token}, audit: audit) do
155 {:ok, _} ->
156 conn
157 |> put_resp_header("content-type", "application/json")
158 2 |> send_resp(200, Jason.encode!(%{}))
159
160 {:error, reason} ->
161 conn
162 |> put_resp_header("content-type", "application/json")
163 0 |> send_resp(422, Jason.encode!(reason))
164 end
165 end)
166 end
167
168 def cancel_billing(conn, %{"dashboard_org" => organization}) do
169 3 access_organization(conn, organization, "admin", fn organization ->
170 3 audit = %{audit_data: audit_data(conn), organization: organization}
171 3 customer = Hexpm.Billing.cancel(organization.name, audit: audit)
172
173 3 message = cancel_message(customer["subscription"]["current_period_end"])
174
175 conn
176 |> put_flash(:info, message)
177 3 |> redirect(to: Routes.organization_path(conn, :show, organization))
178 end)
179 end
180
181 def show_invoice(conn, %{"dashboard_org" => organization, "id" => id}) do
182 1 access_organization(conn, organization, "admin", fn organization ->
183 1 id = String.to_integer(id)
184 1 customer = Hexpm.Billing.get(organization.name)
185 1 invoice_ids = Enum.map(customer["invoices"], & &1["id"])
186
187 1 if id in invoice_ids do
188 1 invoice = Hexpm.Billing.invoice(id)
189
190 conn
191 |> put_resp_header("content-type", "text/html")
192 1 |> send_resp(200, invoice)
193 else
194 0 not_found(conn)
195 end
196 end)
197 end
198
199 def pay_invoice(conn, %{"dashboard_org" => organization, "id" => id}) do
200 3 access_organization(conn, organization, "admin", fn organization ->
201 3 id = String.to_integer(id)
202 3 customer = Hexpm.Billing.get(organization.name)
203 3 invoice_ids = Enum.map(customer["invoices"], & &1["id"])
204
205 3 audit = %{audit_data: audit_data(conn), organization: organization}
206
207 3 if id in invoice_ids do
208 3 case Hexpm.Billing.pay_invoice(id, audit: audit) do
209 :ok ->
210 conn
211 |> put_flash(:info, "Invoice paid.")
212 2 |> redirect(to: Routes.organization_path(conn, :show, organization))
213
214 {:error, reason} ->
215 conn
216 |> put_status(400)
217 1 |> put_flash(:error, "Failed to pay invoice: #{reason["errors"]}.")
218 1 |> render_index(organization)
219 end
220 else
221 0 not_found(conn)
222 end
223 end)
224 end
225
226 def update_billing(conn, %{"dashboard_org" => organization} = params) do
227 2 access_organization(conn, organization, "admin", fn organization ->
228 2 audit = %{audit_data: audit_data(conn), organization: organization}
229
230 2 update_billing(
231 conn,
232 organization,
233 params,
234 2 &Hexpm.Billing.update(organization.name, &1, audit: audit)
235 )
236 end)
237 end
238
239 def create_billing(conn, %{"dashboard_org" => organization} = params) do
240 2 access_organization(conn, organization, "admin", fn organization ->
241 2 user_count = Organizations.user_count(organization)
242
243 2 params =
244 params
245 2 |> Map.put("token", organization.name)
246 |> Map.put("quantity", user_count)
247
248 2 audit = %{audit_data: audit_data(conn), organization: organization}
249
250 2 update_billing(conn, organization, params, &Hexpm.Billing.create(&1, audit: audit))
251 end)
252 end
253
254 @not_enough_seats "The number of open seats cannot be less than the number of organization members."
255
256 def add_seats(conn, %{"dashboard_org" => organization} = params) do
257 3 access_organization(conn, organization, "admin", fn organization ->
258 3 user_count = Organizations.user_count(organization)
259 3 current_seats = String.to_integer(params["current-seats"])
260 3 add_seats = String.to_integer(params["add-seats"])
261 3 seats = current_seats + add_seats
262
263 3 if seats >= user_count do
264 2 audit = %{audit_data: audit_data(conn), organization: organization}
265
266 2 {:ok, _customer} =
267 2 Hexpm.Billing.update(organization.name, %{"quantity" => seats}, audit: audit)
268
269 conn
270 |> put_flash(:info, "The number of open seats have been increased.")
271 2 |> redirect(to: Routes.organization_path(conn, :show, organization))
272 else
273 conn
274 |> put_status(400)
275 |> put_flash(:error, @not_enough_seats)
276 1 |> render_index(organization)
277 end
278 end)
279 end
280
281 def remove_seats(conn, %{"dashboard_org" => organization} = params) do
282 3 access_organization(conn, organization, "admin", fn organization ->
283 3 user_count = Organizations.user_count(organization)
284 3 seats = String.to_integer(params["seats"])
285
286 3 if seats >= user_count do
287 2 audit = %{audit_data: audit_data(conn), organization: organization}
288
289 2 {:ok, _customer} =
290 2 Hexpm.Billing.update(organization.name, %{"quantity" => seats}, audit: audit)
291
292 conn
293 |> put_flash(:info, "The number of open seats have been reduced.")
294 2 |> redirect(to: Routes.organization_path(conn, :show, organization))
295 else
296 conn
297 |> put_status(400)
298 |> put_flash(:error, @not_enough_seats)
299 1 |> render_index(organization)
300 end
301 end)
302 end
303
304 def change_plan(conn, %{"dashboard_org" => organization} = params) do
305 2 access_organization(conn, organization, "admin", fn organization ->
306 2 audit = %{audit_data: audit_data(conn), organization: organization}
307
308 2 Hexpm.Billing.change_plan(organization.name, %{"plan_id" => params["plan_id"]}, audit: audit)
309
310 conn
311 2 |> put_flash(:info, "You have switched to the #{plan_name(params["plan_id"])} plan.")
312 2 |> redirect(to: Routes.organization_path(conn, :show, organization))
313 end)
314 end
315
316 0 defp plan_name("organization-monthly"), do: "monthly organization"
317 2 defp plan_name("organization-annually"), do: "annual organization"
318
319 def new(conn, _params) do
320 0 render_new(conn)
321 end
322
323 def create(conn, params) do
324 2 user = conn.assigns.current_user
325
326 2 case Organizations.create(user, params["organization"], audit: audit_data(conn)) do
327 {:ok, organization} ->
328 conn
329 |> put_flash(:info, "Organization created with one month free trial period active.")
330 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
331
332 {:error, changeset} ->
333 conn
334 |> put_status(400)
335 1 |> render_new(changeset: changeset, params: params)
336 end
337 end
338
339 defp update_billing(conn, organization, params, fun) do
340 4 customer_params =
341 params
342 |> Map.take(["email", "person", "company", "token", "quantity"])
343 |> Map.put_new("person", nil)
344 |> Map.put_new("company", nil)
345
346 4 case fun.(customer_params) do
347 {:ok, _} ->
348 conn
349 |> put_flash(:info, "Updated your billing information.")
350 4 |> redirect(to: Routes.organization_path(conn, :show, organization))
351
352 {:error, reason} ->
353 conn
354 |> put_status(400)
355 |> put_flash(:error, "Failed to update billing information.")
356 0 |> render_index(organization, params: params, errors: reason["errors"])
357 end
358 end
359
360 def create_key(conn, %{"dashboard_org" => organization} = params) do
361 1 access_organization(conn, organization, "write", fn organization ->
362 1 key_params = KeyController.munge_permissions(params["key"])
363
364 1 case Keys.create(organization, key_params, audit: audit_data(conn)) do
365 {:ok, %{key: key}} ->
366 1 flash =
367 1 "The key #{key.name} was successfully generated, " <>
368 1 "copy the secret \"#{key.user_secret}\", you won't be able to see it again."
369
370 conn
371 |> put_flash(:info, flash)
372 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
373
374 {:error, :key, changeset, _} ->
375 conn
376 |> put_status(400)
377 0 |> render_index(organization, key_changeset: changeset)
378 end
379 end)
380 end
381
382 def delete_key(conn, %{"dashboard_org" => organization, "name" => name}) do
383 2 access_organization(conn, organization, "write", fn organization ->
384 2 case Keys.revoke(organization, name, audit: audit_data(conn)) do
385 {:ok, _struct} ->
386 conn
387 1 |> put_flash(:info, "The key #{name} was revoked successfully.")
388 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
389
390 {:error, _} ->
391 conn
392 |> put_status(400)
393 1 |> put_flash(:error, "The key #{name} was not found.")
394 1 |> render_index(organization)
395 end
396 end)
397 end
398
399 def update_profile(conn, %{"dashboard_org" => organization, "profile" => profile_params}) do
400 3 access_organization(conn, organization, "admin", fn organization ->
401 2 case Users.update_profile(organization.user, profile_params, audit: audit_data(conn)) do
402 {:ok, _updated_user} ->
403 conn
404 |> put_flash(:info, "Profile updated successfully.")
405 1 |> redirect(to: Routes.organization_path(conn, :show, organization))
406
407 {:error, _} ->
408 conn
409 |> put_status(400)
410 |> put_flash(:error, "Oops, something went wrong!")
411 1 |> render_index(organization)
412 end
413 end)
414 end
415
416 defp render_new(conn, opts \\ []) do
417 1 render(
418 conn,
419 "new.html",
420 title: "Dashboard - Organization sign up",
421 container: "container page dashboard",
422 billing_email: nil,
423 person: nil,
424 company: nil,
425 params: opts[:params],
426 errors: opts[:errors],
427 1 changeset: opts[:changeset] || create_changeset()
428 )
429 end
430
431 defp render_index(conn, organization, opts \\ []) do
432 11 user = organization.user
433 11 public_email = user && Enum.find(user.emails, & &1.public)
434 11 gravatar_email = user && Enum.find(user.emails, & &1.gravatar)
435 11 customer = Hexpm.Billing.get(organization.name)
436 11 keys = Keys.all(organization)
437 11 delete_key_path = Routes.organization_path(Endpoint, :delete_key, organization)
438 11 create_key_path = Routes.organization_path(Endpoint, :create_key, organization)
439
440 11 assigns = [
441 title: "Dashboard - Organization",
442 container: "container page dashboard",
443 11 changeset: user && User.update_profile(user, %{}),
444 11 public_email: public_email && public_email.email,
445 11 gravatar_email: gravatar_email && gravatar_email.email,
446 organization: organization,
447 11 repository: organization.repository,
448 keys: keys,
449 params: opts[:params],
450 errors: opts[:errors],
451 delete_key_path: delete_key_path,
452 create_key_path: create_key_path,
453 11 key_changeset: opts[:key_changeset] || key_changeset(),
454 11 add_member_changeset: opts[:add_member_changeset] || add_member_changeset()
455 ]
456
457 11 assigns = Keyword.merge(assigns, customer_assigns(customer, organization))
458 11 render(conn, "index.html", assigns)
459 end
460
461 0 defp customer_assigns(nil, _organization) do
462 [
463 billing_started?: false,
464 billing_active?: false,
465 checkout_html: nil,
466 billing_email: nil,
467 plan_id: "organization-monthly",
468 subscription: nil,
469 monthly_cost: nil,
470 amount_with_tax: nil,
471 quantity: nil,
472 max_period_quantity: nil,
473 card: nil,
474 invoices: nil,
475 person: nil,
476 company: nil,
477 post_action: nil,
478 csrf_token: nil
479 ]
480 end
481
482 defp customer_assigns(customer, organization) do
483 11 post_action = Routes.organization_path(Endpoint, :billing_token, organization)
484
485 [
486 billing_started?: true,
487 11 billing_active?: !!customer["subscription"],
488 checkout_html: customer["checkout_html"],
489 billing_email: customer["email"],
490 plan_id: customer["plan_id"],
491 proration_amount: customer["proration_amount"],
492 proration_days: customer["proration_days"],
493 subscription: customer["subscription"],
494 monthly_cost: customer["monthly_cost"],
495 amount_with_tax: customer["amount_with_tax"],
496 quantity: customer["quantity"],
497 max_period_quantity: customer["max_period_quantity"],
498 tax_rate: customer["tax_rate"],
499 discount: customer["discount"],
500 card: customer["card"],
501 invoices: customer["invoices"],
502 person: customer["person"],
503 company: customer["company"],
504 post_action: post_action,
505 csrf_token: get_csrf_token()
506 ]
507 end
508
509 defp access_organization(conn, organization, role, fun) do
510 37 user = conn.assigns.current_user
511
512 37 organization =
513 Organizations.get(organization, [
514 :user,
515 :organization_users,
516 user: :emails,
517 users: :emails,
518 repository: :packages
519 ])
520
521 37 if organization do
522 37 if repo_user = Enum.find(organization.organization_users, &(&1.user_id == user.id)) do
523 36 if repo_user.role in Organization.role_or_higher(role) do
524 35 fun.(organization)
525 else
526 conn
527 |> put_status(400)
528 |> put_flash(:error, "You do not have permission for this action.")
529 1 |> render_index(organization)
530 end
531 else
532 1 not_found(conn)
533 end
534 else
535 0 not_found(conn)
536 end
537 end
538
539 defp add_member_changeset() do
540 11 Organization.add_member(%OrganizationUser{}, %{})
541 end
542
543 defp create_changeset() do
544 0 Organization.changeset(%Organization{}, %{})
545 end
546
547 defp key_changeset() do
548 11 Key.changeset(%Key{}, %{}, %{})
549 end
550
551 1 defp cancel_message(nil = _cancel_date) do
552 "Your subscription is cancelled"
553 end
554
555 defp cancel_message(cancel_date) do
556 2 date = HexpmWeb.Dashboard.OrganizationView.payment_date(cancel_date)
557
558 2 "Your subscription is cancelled, you will have access to the organization until " <>
559 2 "the end of your billing period at #{date}"
560 end
561 end

lib/hexpm_web/controllers/dashboard/password_controller.ex

90
10
20
1
Line Hits Source
0 defmodule HexpmWeb.Dashboard.PasswordController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 user = conn.assigns.current_user
7 1 render_index(conn, User.update_password(user, %{}))
8 end
9
10 def update(conn, params) do
11 4 user = conn.assigns.current_user
12
13 4 case Users.update_password(user, params["user"], audit: audit_data(conn)) do
14 {:ok, _user} ->
15 1 breached? = Hexpm.Pwned.password_breached?(params["user"]["password"])
16
17 conn
18 |> put_flash(:info, "Your password has been updated.")
19 |> maybe_put_flash(breached?)
20 1 |> redirect(to: Routes.dashboard_password_path(conn, :index))
21
22 {:error, changeset} ->
23 conn
24 |> put_status(400)
25 3 |> render_index(changeset)
26 end
27 end
28
29 defp render_index(conn, changeset) do
30 4 render(
31 conn,
32 "index.html",
33 title: "Dashboard - Change password",
34 container: "container page dashboard",
35 changeset: changeset
36 )
37 end
38
39 1 defp maybe_put_flash(conn, false), do: conn
40
41 defp maybe_put_flash(conn, true) do
42 0 put_flash(conn, :raw_error, password_breached_message(conn, []))
43 end
44 end

lib/hexpm_web/controllers/dashboard/profile_controller.ex

85.7
7
18
1
Line Hits Source
0 defmodule HexpmWeb.Dashboard.ProfileController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 user = conn.assigns.current_user
7 1 render_index(conn, User.update_profile(user, %{}))
8 end
9
10 def update(conn, params) do
11 5 user = conn.assigns.current_user
12
13 5 case Users.update_profile(user, params["user"], audit: audit_data(conn)) do
14 {:ok, _user} ->
15 conn
16 |> put_flash(:info, "Profile updated successfully.")
17 5 |> redirect(to: Routes.profile_path(conn, :index))
18
19 {:error, changeset} ->
20 conn
21 |> put_status(400)
22 0 |> render_index(changeset)
23 end
24 end
25
26 defp render_index(conn, changeset) do
27 1 render(
28 conn,
29 "index.html",
30 title: "Dashboard - Public profile",
31 container: "container page dashboard",
32 changeset: changeset
33 )
34 end
35 end

lib/hexpm_web/controllers/dashboard/security_controller.ex

100
17
19
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.SecurityController do
1 use HexpmWeb, :controller
2 alias Hexpm.Accounts.User
3
4 plug :requires_login
5
6 def index(conn, _params) do
7 2 user = conn.assigns.current_user
8
9 2 if User.tfa_enabled?(user) and not user.tfa.app_enabled do
10 conn
11 |> put_flash(:error, "Please complete your two-factor authentication setup")
12 1 |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index))
13 else
14 1 render_index(conn)
15 end
16 end
17
18 def enable_tfa(conn, _params) do
19 1 user = conn.assigns.current_user
20 1 Users.tfa_enable(user, audit: audit_data(conn))
21
22 conn
23 |> put_flash(:info, "Two factor authentication has been enabled.")
24 1 |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index))
25 end
26
27 def disable_tfa(conn, _params) do
28 1 user = conn.assigns.current_user
29 1 Users.tfa_disable(user, audit: audit_data(conn))
30
31 conn
32 |> put_flash(:info, "Two factor authentication has been disabled.")
33 1 |> redirect(to: Routes.dashboard_security_path(conn, :index))
34 end
35
36 def rotate_recovery_codes(conn, _params) do
37 1 user = conn.assigns.current_user
38 1 Users.tfa_rotate_recovery_codes(user, audit: audit_data(conn))
39
40 conn
41 |> put_flash(:info, "New two-factor recovery codes successfully generated.")
42 1 |> redirect(to: Routes.dashboard_security_path(conn, :index))
43 end
44
45 def reset_auth_app(conn, _params) do
46 1 user = conn.assigns.current_user
47 1 Users.tfa_disable_app(user, audit: audit_data(conn))
48
49 conn
50 |> put_flash(:info, "Please complete your two-factor authentication setup")
51 1 |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index))
52 end
53
54 defp render_index(conn) do
55 1 render(
56 conn,
57 "index.html",
58 title: "Dashboard - Security",
59 container: "container page dashboard"
60 )
61 end
62 end

lib/hexpm_web/controllers/dashboard/tfa_setup_controller.ex

100
5
7
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.TFAAuthSetupController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 render(
7 conn,
8 "index.html",
9 title: "Dashboard - Two-factor authentication setup",
10 container: "container page dashboard"
11 )
12 end
13
14 def create(conn, %{"verification_code" => verification_code}) do
15 2 user = conn.assigns.current_user
16
17 2 case Users.tfa_enable_app(user, verification_code, audit: audit_data(conn)) do
18 {:ok, _user} ->
19 conn
20 |> put_flash(:info, "Two-factor authentication has been enabled.")
21 1 |> redirect(to: Routes.dashboard_security_path(conn, :index))
22
23 :error ->
24 conn
25 |> put_flash(:error, "Your verification code was incorrect.")
26 1 |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index))
27 end
28 end
29 end

lib/hexpm_web/controllers/dashboard_controller.ex

100
1
1
0
Line Hits Source
0 defmodule HexpmWeb.DashboardController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 def index(conn, _params) do
6 1 redirect(conn, to: Routes.profile_path(conn, :index))
7 end
8 end

lib/hexpm_web/controllers/docs_controller.ex

0
15
0
15
Line Hits Source
0 defmodule HexpmWeb.DocsController do
1 use HexpmWeb, :controller
2
3 def index(conn, _params) do
4 0 redirect(conn, to: Routes.docs_path(conn, :usage))
5 end
6
7 def usage(conn, _params) do
8 0 render(
9 conn,
10 "layout.html",
11 view: "usage.html",
12 view_name: :usage,
13 title: "Mix usage",
14 container: "container page docs"
15 )
16 end
17
18 def publish(conn, _params) do
19 0 render(
20 conn,
21 "layout.html",
22 view: "publish.html",
23 view_name: :publish,
24 title: "Mix publish package",
25 container: "container page docs"
26 )
27 end
28
29 def tasks(conn, _params) do
30 0 redirect(conn, external: "https://hexdocs.pm/hex")
31 end
32
33 def rebar3_usage(conn, _params) do
34 0 render(
35 conn,
36 "layout.html",
37 view: "rebar3_usage.html",
38 view_name: :rebar3_usage,
39 title: "Rebar3 usage",
40 container: "container page docs"
41 )
42 end
43
44 def rebar3_publish(conn, _params) do
45 0 render(
46 conn,
47 "layout.html",
48 view: "rebar3_publish.html",
49 view_name: :rebar3_publish,
50 title: "Rebar3 publish package",
51 container: "container page docs"
52 )
53 end
54
55 def rebar3_private(conn, _params) do
56 0 render(
57 conn,
58 "layout.html",
59 view: "rebar3_private.html",
60 view_name: :rebar3_private,
61 title: "Rebar3 private packages",
62 container: "container page docs"
63 )
64 end
65
66 def rebar3_tasks(conn, _params) do
67 0 url = "https://rebar3.org/docs/package_management/hex_package_management/"
68 0 redirect(conn, external: url)
69 end
70
71 def private(conn, _params) do
72 0 render(
73 conn,
74 "layout.html",
75 view: "private.html",
76 view_name: :private,
77 title: "Private packages",
78 container: "container page docs"
79 )
80 end
81
82 def coc(conn, _params) do
83 0 render(
84 conn,
85 "layout.html",
86 view: "coc.html",
87 view_name: :coc,
88 title: "Code of Conduct",
89 container: "container page docs"
90 )
91 end
92
93 def faq(conn, _params) do
94 0 render(
95 conn,
96 "layout.html",
97 view: "faq.html",
98 view_name: :faq,
99 title: "FAQ",
100 container: "container page docs"
101 )
102 end
103
104 def mirrors(conn, _params) do
105 0 render(
106 conn,
107 "layout.html",
108 view: "mirrors.html",
109 view_name: :mirrors,
110 title: "Mirrors",
111 container: "container page docs"
112 )
113 end
114
115 def public_keys(conn, _params) do
116 0 render(
117 conn,
118 "layout.html",
119 view: "public_keys.html",
120 view_name: :public_keys,
121 title: "Public keys",
122 container: "container page docs"
123 )
124 end
125
126 def self_hosting(conn, _params) do
127 0 render(
128 conn,
129 "layout.html",
130 view: "self_hosting.html",
131 view_name: :self_hosting,
132 title: "Self-hosting",
133 container: "container page docs"
134 )
135 end
136 end

lib/hexpm_web/controllers/email_verification_controller.ex

100
11
37
0
Line Hits Source
0 defmodule HexpmWeb.EmailVerificationController do
1 use HexpmWeb, :controller
2
3 def verify(conn, %{"username" => username, "email" => email, "key" => key}) do
4 6 success = Users.verify_email(username, email, key) == :ok
5
6 6 conn =
7 if success do
8 3 put_flash(conn, :info, "Your email #{email} has been verified.")
9 else
10 3 put_flash(conn, :error, "Your email #{email} failed to verify.")
11 end
12
13 6 redirect(conn, to: Routes.page_path(HexpmWeb.Endpoint, :index))
14 end
15
16 def show(conn, _params) do
17 1 render(
18 conn,
19 "show.html",
20 title: "Verify email",
21 container: "container page page-xs"
22 )
23 end
24
25 def create(conn, %{"email" => email_address}) do
26 3 if email = Users.get_email(email_address, [:user]) do
27 2 unless email.verified do
28 1 Users.email_verification(email.user, email)
29 end
30 end
31
32 conn
33 3 |> put_flash(:info, "A verification email has been sent to #{email_address}.")
34 3 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
35 end
36 end

lib/hexpm_web/controllers/feeds_controller.ex

100
1
1
0
Line Hits Source
0 defmodule HexpmWeb.FeedsController do
1 use HexpmWeb, :controller
2
3 def blog(conn, _params) do
4 conn
5 |> put_view(HexpmWeb.BlogView)
6 |> put_resp_content_type("application/rss+xml")
7 1 |> render("index.xml")
8 end
9 end

lib/hexpm_web/controllers/install_controller.ex

100
10
58
0
Line Hits Source
0 defmodule HexpmWeb.InstallController do
1 use HexpmWeb, :controller
2
3 def archive(conn, params) do
4 9 user_agent = get_req_header(conn, "user-agent")
5 9 current = params["elixir"] || version_from_user_agent(user_agent)
6 9 all_versions = Installs.all()
7
8 9 url =
9 case Install.latest(all_versions, current) do
10 {:ok, _hex, elixir} ->
11 8 "installs/#{elixir}/hex.ez"
12
13 1 :error ->
14 "installs/hex.ez"
15 end
16
17 conn
18 |> cache([:public, "max-age": 60 * 60], [])
19 9 |> redirect(external: Hexpm.Utils.cdn_url(url))
20 end
21
22 defp version_from_user_agent(user_agent) do
23 2 case List.first(user_agent) do
24 1 "Mix/" <> version -> version
25 1 _ -> "1.0.0"
26 end
27 end
28 end

lib/hexpm_web/controllers/login_controller.ex

92.9
28
124
2
Line Hits Source
0 defmodule HexpmWeb.LoginController do
1 use HexpmWeb, :controller
2
3 plug :nillify_params, ["return"]
4
5 def show(conn, _params) do
6 1 if logged_in?(conn) do
7 0 redirect_return(conn, conn.assigns.current_user, conn.params["return"])
8 else
9 1 render_show(conn)
10 end
11 end
12
13 def create(conn, %{"username" => username, "password" => password}) do
14 12 case password_auth(username, password) do
15 {:ok, user} ->
16 10 breached? = Hexpm.Pwned.password_breached?(password)
17 10 login(conn, user, password_breached: breached?)
18
19 {:error, reason} ->
20 conn
21 |> put_flash(:error, auth_error_message(reason))
22 |> put_status(400)
23 2 |> render_show()
24 end
25 end
26
27 def delete(conn, _params) do
28 conn
29 |> delete_session("user_id")
30 1 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
31 end
32
33 def start_session(conn, user, return) do
34 conn
35 |> configure_session(renew: true)
36 11 |> put_session("user_id", user.id)
37 11 |> redirect_return(user, return)
38 end
39
40 defp redirect_return(%{params: %{"hexdocs" => organization}} = conn, user, return) do
41 3 case generate_hexdocs_key(user, organization) do
42 {:ok, key} ->
43 2 docs_url =
44 Application.get_env(:hexpm, :docs_url)
45 2 |> String.replace("://", "://#{organization}.")
46
47 2 url = "#{docs_url}#{return}?key=#{key.user_secret}"
48 2 redirect(conn, external: url)
49
50 {:error, _changeset} ->
51 conn
52 1 |> put_flash(:error, "You don't have access to organization #{organization}")
53 |> put_status(400)
54 1 |> render_show()
55 end
56 end
57
58 defp redirect_return(conn, user, return) do
59 8 path = return || Routes.user_path(conn, :show, user)
60 8 redirect(conn, to: path)
61 end
62
63 defp generate_hexdocs_key(user, organization) do
64 3 Keys.create_for_docs(user, organization)
65 end
66
67 defp render_show(conn) do
68 4 render(
69 conn,
70 "show.html",
71 title: "Log in",
72 container: "container page page-xs login",
73 4 return: conn.params["return"],
74 4 hexdocs: conn.params["hexdocs"]
75 )
76 end
77
78 defp login(conn, %User{id: user_id, tfa: %{tfa_enabled: true, app_enabled: true}},
79 password_breached: breached?
80 ) do
81 conn
82 |> configure_session(renew: true)
83 1 |> put_session("tfa_user_id", %{uid: user_id, return: conn.params["return"]})
84 |> maybe_put_flash(breached?)
85 1 |> redirect(to: Routes.tfa_auth_path(conn, :show))
86 end
87
88 defp login(conn, user, password_breached: breached?) do
89 conn
90 |> maybe_put_flash(breached?)
91 9 |> start_session(user, conn.params["return"])
92 end
93
94 10 defp maybe_put_flash(conn, false), do: conn
95
96 defp maybe_put_flash(conn, true) do
97 0 put_flash(conn, :raw_error, password_breached_message(conn, []))
98 end
99 end

lib/hexpm_web/controllers/opensearch_controller.ex

100
1
1
0
Line Hits Source
0 defmodule HexpmWeb.OpenSearchController do
1 use HexpmWeb, :controller
2
3 def opensearch(conn, _params) do
4 conn
5 |> put_resp_content_type("text/xml")
6 1 |> render("opensearch.xml")
7 end
8 end

lib/hexpm_web/controllers/package_controller.ex

92.6
81
1126
6
Line Hits Source
0 defmodule HexpmWeb.PackageController do
1 use HexpmWeb, :controller
2
3 @packages_per_page 30
4 @audit_logs_per_page 10
5 @sort_params ~w(name recent_downloads total_downloads inserted_at updated_at)
6 @letters for letter <- ?A..?Z, do: <<letter>>
7
8 def index(conn, params) do
9 8 letter = Hexpm.Utils.parse_search(params["letter"])
10 8 search = Hexpm.Utils.parse_search(params["search"])
11
12 8 filter =
13 cond do
14 2 letter ->
15 {:letter, letter}
16
17 6 search ->
18 4 search
19
20 2 true ->
21 nil
22 end
23
24 8 organizations = Users.all_organizations(conn.assigns.current_user)
25 8 repositories = Enum.map(organizations, & &1.repository)
26 8 sort = sort(params["sort"])
27 8 page_param = Hexpm.Utils.safe_int(params["page"]) || 1
28 8 package_count = Packages.count(repositories, filter)
29 8 page = Hexpm.Utils.safe_page(page_param, package_count, @packages_per_page)
30 8 packages = fetch_packages(repositories, page, @packages_per_page, filter, sort)
31 8 downloads = Packages.packages_downloads_with_all_views(packages)
32 8 exact_match = exact_match(repositories, search)
33
34 8 render(
35 conn,
36 "index.html",
37 title: "Packages",
38 container: "container",
39 per_page: @packages_per_page,
40 search: search,
41 letter: letter,
42 sort: sort,
43 package_count: package_count,
44 page: page,
45 packages: packages,
46 letters: @letters,
47 downloads: downloads,
48 exact_match: exact_match
49 )
50 end
51
52 def show(conn, params) do
53 # TODO: Show flash if private package and organization does not have active billing
54
55 15 params = fixup_params(params)
56
57 15 access_package(conn, params, fn package, repositories ->
58 9 releases = Releases.all(package)
59
60 9 {release, type} =
61 9 if version = params["version"] do
62 {matching_release(releases, version), :release}
63 else
64 {Release.latest_version(releases, only_stable: true, unstable_fallback: true), :package}
65 end
66
67 9 if release do
68 9 package(conn, repositories, package, releases, release, type)
69 else
70 0 not_found(conn)
71 end
72 end)
73 end
74
75 def audit_logs(conn, params) do
76 3 access_package(conn, params, fn package, _ ->
77 2 page = Hexpm.Utils.safe_int(params["page"]) || 1
78 2 per_page = 100
79 2 audit_logs = AuditLogs.all_by(package, page, per_page)
80 2 total_count = AuditLogs.count_by(package)
81
82 2 render(conn, "audit_logs.html",
83 2 title: "Recent Activities for #{package.name}",
84 container: "container package-view",
85 package: package,
86 audit_logs: audit_logs,
87 page: page,
88 per_page: per_page,
89 total_count: total_count
90 )
91 end)
92 end
93
94 defp access_package(conn, params, fun) do
95 18 %{"repository" => repository, "name" => name} = params
96 18 organizations = Users.all_organizations(conn.assigns.current_user)
97 18 repositories = Map.new(organizations, &{&1.repository.name, &1.repository})
98
99 4 if repository = repositories[repository] do
100 14 package = repository && Packages.get(repository, name)
101
102 # Should have access even though organization does not have active billing
103 14 if package do
104 11 fun.(package, Enum.map(organizations, & &1.repository))
105 end
106 18 end || not_found(conn)
107 end
108
109 8 defp sort(nil), do: sort("recent_downloads")
110 0 defp sort("downloads"), do: sort("recent_downloads")
111 8 defp sort(param), do: Hexpm.Utils.safe_to_atom(param, @sort_params)
112
113 defp matching_release(releases, version) do
114 4 Enum.find(releases, &(to_string(&1.version) == version))
115 end
116
117 defp package(conn, repositories, package, releases, release, type) do
118 9 repository = package.repository
119 9 release = Releases.preload(release, [:requirements, :downloads, :publisher])
120
121 9 latest_release_with_docs =
122 Release.latest_version(releases, only_stable: true, unstable_fallback: true, with_docs: true)
123
124 9 docs_assigns =
125 cond do
126 9 type == :package && latest_release_with_docs ->
127 [
128 docs_html_url: Hexpm.Utils.docs_html_url(repository, package, nil),
129 docs_tarball_url:
130 Hexpm.Utils.docs_tarball_url(repository, package, latest_release_with_docs)
131 ]
132
133 4 type == :release and release.has_docs ->
134 [
135 docs_html_url: Hexpm.Utils.docs_html_url(repository, package, release),
136 docs_tarball_url: Hexpm.Utils.docs_tarball_url(repository, package, release)
137 ]
138
139 2 true ->
140 [docs_html_url: nil, docs_tarball_url: nil]
141 end
142
143 9 downloads = Packages.package_downloads(package)
144
145 9 graph_downloads =
146 case type do
147 5 :package -> Packages.downloads_for_last_n_days(package.id, 31)
148 4 :release -> Releases.downloads_for_last_n_days(release.id, 31)
149 end
150
151 9 daily_graph =
152 Date.utc_today()
153 |> Date.add(-31)
154 |> Date.range(Date.add(Date.utc_today(), -1))
155 |> Enum.map(fn date ->
156 279 Enum.find(graph_downloads, fn dl -> date == Date.from_iso8601!(dl.day) end)
157 end)
158 |> Enum.map(fn
159 279 nil -> 0
160 0 %{downloads: dl} -> dl
161 end)
162
163 9 owners = Owners.all(package, user: [:emails, :organization])
164
165 9 dependants =
166 Packages.search(
167 repositories,
168 1,
169 20,
170 9 "depends:#{repository.name}:#{package.name}",
171 :recent_downloads,
172 [:name, :repository_id]
173 )
174
175 9 dependants_count = Packages.count(repositories, "depends:#{repository.name}:#{package.name}")
176
177 9 audit_logs = AuditLogs.all_by(package, 1, @audit_logs_per_page)
178
179 9 render(
180 conn,
181 "show.html",
182 [
183 9 title: package.name,
184 9 description: package.meta.description,
185 container: "container package-view",
186 canonical_url: Routes.package_url(conn, :show, package),
187 package: package,
188 9 repository_name: repository.name,
189 releases: releases,
190 current_release: release,
191 downloads: downloads,
192 owners: owners,
193 dependants: dependants,
194 dependants_count: dependants_count,
195 audit_logs: audit_logs,
196 daily_graph: daily_graph,
197 type: type
198 ] ++ docs_assigns
199 )
200 end
201
202 defp fetch_packages(repositories, page, packages_per_page, search, sort) do
203 8 packages = Packages.search(repositories, page, packages_per_page, search, sort, nil)
204 8 Packages.attach_versions(packages)
205 end
206
207 4 defp exact_match(_organizations, nil) do
208 nil
209 end
210
211 defp exact_match(repositories, search) do
212 search
213 |> String.replace(" ", "_")
214 |> String.split("/", parts: 2)
215 4 |> case do
216 [repository, package] ->
217 0 if repository in Enum.map(repositories, & &1.name) do
218 0 Packages.get(repository, package)
219 end
220
221 [term] ->
222 4 try do
223 4 Packages.get(repositories, term)
224 rescue
225 0 Ecto.MultipleResultsError ->
226 nil
227 end
228 end
229 end
230
231 defp fixup_params(%{"name" => name, "version" => version} = params) do
232 10 case Version.parse(version) do
233 {:ok, _} ->
234 7 params
235
236 :error ->
237 params
238 |> Map.put("repository", name)
239 |> Map.put("name", version)
240 3 |> Map.delete("version")
241 end
242 end
243
244 defp fixup_params(params) do
245 5 params
246 end
247 end

lib/hexpm_web/controllers/package_report_controller.ex

34.6
81
290
53
Line Hits Source
0 defmodule HexpmWeb.PackageReportController do
1 use HexpmWeb, :controller
2
3 plug :requires_login
4
5 @new_report_msg "Package report generated"
6 @report_updated_msg "Package report updated"
7 @report_bad_update_msg "Package report can not be updated"
8 @report_bad_version_msg "No release matches given requirement"
9
10 def new_comment(conn, params) do
11 0 report = PackageReports.get(params["id"])
12 0 author = conn.assigns.current_user
13 0 PackageReports.new_comment(report, author, params)
14
15 0 redirect(conn, to: Routes.package_report_path(HexpmWeb.Endpoint, :show, report.id))
16 end
17
18 def index(conn, _params) do
19 1 reports = PackageReports.all()
20 1 reports_count = Enum.count(reports)
21
22 1 render(
23 conn,
24 "index.html",
25 reports: reports,
26 total: reports_count
27 )
28 end
29
30 def new(conn, params) do
31 0 package = params["package"]
32
33 0 if package do
34 0 build_report_form(conn, params)
35 else
36 0 not_found(conn)
37 end
38 end
39
40 def create(conn, params) do
41 0 description = params["description"]
42 0 package_name = params["package"]
43 0 state = "to_accept"
44 0 requirement = params["requirement"]
45 0 repository = params["repository"]
46
47 0 package = Packages.get(repository, package_name)
48
49 0 user = conn.assigns.current_user
50 0 all_releases = Releases.all(package)
51
52 0 report_releases = slice_releases(all_releases, requirement)
53
54 0 if report_releases == [] do
55 conn
56 |> put_flash(:error, @report_bad_version_msg)
57 |> put_status(400)
58 0 |> new(%{
59 "repository" => repository,
60 "package" => package_name,
61 "description" => description
62 })
63 else
64 %{
65 "package" => package,
66 "releases" => report_releases,
67 "user" => user,
68 "description" => description,
69 "state" => state
70 }
71 0 |> PackageReports.add()
72
73 conn
74 |> put_flash(:info, @new_report_msg)
75 0 |> redirect(to: Routes.package_report_path(HexpmWeb.Endpoint, :index))
76 end
77 end
78
79 def show(conn, params) do
80 21 report = PackageReports.get(params["id"])
81 21 user = conn.assigns.current_user
82
83 21 if report do
84 20 for_moderator = User.has_role?(user, "moderator")
85 20 for_owner = Owners.get(report.package, user) != nil
86 20 for_author = user.id == report.author.id
87
88 20 if visible_report?(report, user, for_owner) do
89 15 comments = PackageReports.all_comments(report.id)
90
91 15 render(
92 conn,
93 "show.html",
94 report: report,
95 for_moderator: for_moderator,
96 for_owner: for_owner,
97 for_author: for_author,
98 comments: comments
99 )
100 else
101 5 not_found(conn)
102 end
103 else
104 1 not_found(conn)
105 end
106 end
107
108 defp visible_report?(report, user, owner?) do
109 20 moderator? = User.has_role?(user, "moderator")
110 20 author? = user.id == report.author.id
111
112 20 cond do
113 20 report.state in ["to_accept", "rejected"] -> moderator? or author?
114 12 report.state == "accepted" -> moderator? or author? or owner?
115 8 report.state in ["solved", "unresolved"] -> true
116 end
117 end
118
119 def accept_report(conn, params) do
120 0 report_id = params["id"]
121
122 0 report = PackageReports.get(report_id)
123
124 0 if valid_state_change?("accepted", report) and
125 0 User.has_role?(conn.assigns.current_user, "moderator") do
126 0 PackageReports.accept(report_id)
127 0 notify_good_update(conn)
128 else
129 0 notify_bad_update(conn, %{"id" => report_id})
130 end
131 end
132
133 def reject_report(conn, params) do
134 0 report_id = params["id"]
135
136 0 report = PackageReports.get(report_id)
137
138 0 if valid_state_change?("rejected", report) and
139 0 User.has_role?(conn.assigns.current_user, "moderator") do
140 0 PackageReports.reject(report_id)
141
142 0 notify_good_update(conn)
143 else
144 0 notify_bad_update(conn, %{"id" => report_id})
145 end
146 end
147
148 def solve_report(conn, params) do
149 1 report_id = params["id"]
150
151 1 report = PackageReports.get(report_id)
152
153 1 if valid_state_change?("solved", report) and
154 1 User.has_role?(conn.assigns.current_user, "moderator") do
155 1 PackageReports.solve(report_id)
156
157 1 notify_good_update(conn)
158 else
159 0 notify_bad_update(conn, %{"id" => report_id})
160 end
161 end
162
163 def unresolve_report(conn, params) do
164 0 report_id = params["id"]
165
166 0 report = PackageReports.get(report_id)
167
168 0 if valid_state_change?("unresolved", report) and
169 0 User.has_role?(conn.assigns.current_user, "moderator") do
170 0 PackageReports.unresolve(report_id)
171
172 0 notify_good_update(conn)
173 else
174 0 notify_bad_update(conn, %{"id" => report_id})
175 end
176 end
177
178 defp notify_good_update(conn) do
179 conn
180 |> put_flash(:info, @report_updated_msg)
181 1 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
182 end
183
184 defp notify_bad_update(conn, params) do
185 conn
186 |> put_flash(:error, @report_bad_update_msg)
187 |> put_status(400)
188 0 |> show(params)
189 end
190
191 0 defp valid_state_change?(new, %{state: "to_accept"}), do: new in ["accepted", "rejected"]
192
193 defp valid_state_change?(new, %{state: "accepted"}),
194 1 do: new in ["solved", "rejected", "unresolved"]
195
196 0 defp valid_state_change?(new, %{state: "rejected"}), do: new in ["accepted"]
197 0 defp valid_state_change?(_new, _), do: false
198
199 defp slice_releases(releases, requirement) do
200 0 case Version.parse_requirement(requirement) do
201 {:ok, requirement} ->
202 0 Enum.filter(releases, &Version.match?(&1.version, requirement))
203
204 0 :error ->
205 []
206 end
207 end
208
209 defp build_report_form(conn, params) do
210 0 %{"repository" => repository, "package" => name} = params
211 0 description = params["description"]
212
213 0 render(
214 conn,
215 "new_report.html",
216 package_name: name,
217 repository: repository,
218 description: description
219 )
220 end
221 end

lib/hexpm_web/controllers/page_controller.ex

100
5
7
0
Line Hits Source
0 defmodule HexpmWeb.PageController do
1 use HexpmWeb, :controller
2
3 def index(conn, _params) do
4 2 hexpm = Repository.hexpm()
5
6 2 render(
7 conn,
8 "index.html",
9 container: "",
10 custom_flash: true,
11 hide_search: true,
12 num_packages: Packages.count(),
13 num_releases: Releases.count(),
14 package_top: Packages.top_downloads(hexpm, "recent", 8),
15 package_new: Packages.recent(hexpm, 10),
16 releases_new: Releases.recent(hexpm, 10),
17 total: Packages.total_downloads()
18 )
19 end
20
21 def about(conn, _params) do
22 1 render(
23 conn,
24 "about.html",
25 title: "About Hex",
26 container: "container page page-sm"
27 )
28 end
29
30 def pricing(conn, _params) do
31 1 render(
32 conn,
33 "pricing.html",
34 title: "Pricing",
35 container: "container page pricing"
36 )
37 end
38
39 def sponsors(conn, _params) do
40 1 render(
41 conn,
42 "sponsors.html",
43 title: "Sponsors",
44 container: "container page page-sm sponsors"
45 )
46 end
47 end

lib/hexpm_web/controllers/password_controller.ex

84.2
19
27
3
Line Hits Source
0 defmodule HexpmWeb.PasswordController do
1 use HexpmWeb, :controller
2
3 def show(conn, %{"username" => username, "key" => key}) do
4 conn
5 |> put_session("reset_username", username)
6 |> put_session("reset_key", key)
7 1 |> redirect(to: Routes.password_path(conn, :show))
8 end
9
10 def show(conn, _params) do
11 1 username = get_session(conn, "reset_username")
12 1 key = get_session(conn, "reset_key")
13
14 1 if username && key do
15 1 changeset = User.update_password(%User{}, %{})
16
17 conn
18 |> delete_session("reset_username")
19 |> delete_session("reset_key")
20 1 |> render_show(username, key, changeset)
21 else
22 conn
23 |> put_flash(:error, "Invalid password reset key.")
24 0 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
25 end
26 end
27
28 def update(conn, params) do
29 3 params = params["user"]
30 3 username = params["username"]
31 3 key = params["key"]
32 3 revoke_all_keys? = (params["revoke_all_keys"] || "yes") == "yes"
33
34 3 case Users.password_reset_finish(
35 username,
36 key,
37 params,
38 revoke_all_keys?,
39 audit: audit_data(conn)
40 ) do
41 :ok ->
42 1 breached? = Hexpm.Pwned.password_breached?(params["password"])
43
44 conn
45 |> clear_session()
46 |> configure_session(renew: true)
47 |> maybe_put_flash(breached?)
48 |> put_flash(:info, "Your account password has been changed to your new password.")
49 1 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
50
51 :error ->
52 conn
53 |> put_flash(:error, "Failed to change your password.")
54 2 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
55
56 {:error, changeset} ->
57 conn
58 |> put_status(400)
59 0 |> render_show(username, key, changeset)
60 end
61 end
62
63 defp render_show(conn, username, key, changeset) do
64 1 render(
65 conn,
66 "show.html",
67 title: "Choose a new password",
68 container: "container page page-xs password-view",
69 username: username,
70 key: key,
71 changeset: changeset
72 )
73 end
74
75 1 defp maybe_put_flash(conn, false), do: conn
76
77 defp maybe_put_flash(conn, true) do
78 0 put_flash(conn, :raw_error, password_breached_message(conn, []))
79 end
80 end

lib/hexpm_web/controllers/password_reset_controller.ex

100
3
5
0
Line Hits Source
0 defmodule HexpmWeb.PasswordResetController do
1 use HexpmWeb, :controller
2
3 def show(conn, _params) do
4 1 render(
5 conn,
6 "show.html",
7 title: "Reset your password",
8 container: "container page page-xs password-view"
9 )
10 end
11
12 def create(conn, %{"username" => name}) do
13 2 Users.password_reset_init(name, audit: audit_data(conn))
14
15 2 render(
16 conn,
17 "create.html",
18 title: "Reset your password",
19 container: "container page page-xs password-view"
20 )
21 end
22 end

lib/hexpm_web/controllers/policy_controller.ex

80
5
4
1
Line Hits Source
0 defmodule HexpmWeb.PolicyController do
1 use HexpmWeb, :controller
2
3 def coc(conn, _params) do
4 1 render(
5 conn,
6 "coc.html",
7 title: "Code of Conduct",
8 container: "container page page-sm policies"
9 )
10 end
11
12 def copyright(conn, _params) do
13 1 render(
14 conn,
15 "copyright.html",
16 title: "Copyright Policy",
17 container: "container page page-sm policies"
18 )
19 end
20
21 def privacy(conn, _params) do
22 1 render(
23 conn,
24 "privacy.html",
25 title: "Privacy Policy",
26 container: "container page page-sm policies"
27 )
28 end
29
30 def tos(conn, _params) do
31 1 render(
32 conn,
33 "tos.html",
34 title: "Terms of Service",
35 container: "container page page-sm policies"
36 )
37 end
38
39 def dispute(conn, _params) do
40 0 render(
41 conn,
42 "dispute.html",
43 title: "Dispute policy",
44 container: "container page page-sm policies"
45 )
46 end
47 end

lib/hexpm_web/controllers/short_url_controller.ex

100
3
4
0
Line Hits Source
0 defmodule HexpmWeb.ShortURLController do
1 use HexpmWeb, :controller
2 alias Hexpm.ShortURLs
3 alias Hexpm.ShortURLs.ShortURL
4
5 def show(conn, %{"short_code" => short_code}) do
6 2 case ShortURLs.get(short_code) do
7 nil ->
8 1 not_found(conn)
9
10 %ShortURL{url: url} ->
11 conn
12 |> put_status(301)
13 1 |> redirect(external: url)
14 end
15 end
16 end

lib/hexpm_web/controllers/signup_controller.ex

77.8
9
9
2
Line Hits Source
0 defmodule HexpmWeb.SignupController do
1 use HexpmWeb, :controller
2
3 def show(conn, _params) do
4 1 if logged_in?(conn) do
5 0 path = Routes.user_path(conn, :show, conn.assigns.current_user)
6 0 redirect(conn, to: path)
7 else
8 1 render_show(conn, User.build(%{}))
9 end
10 end
11
12 def create(conn, params) do
13 2 case Users.add(params["user"], audit: audit_data(conn)) do
14 {:ok, _user} ->
15 1 flash =
16 "A confirmation email has been sent, " <>
17 "you will have access to your account shortly."
18
19 conn
20 |> put_flash(:info, flash)
21 1 |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index))
22
23 {:error, changeset} ->
24 conn
25 |> put_status(400)
26 1 |> render_show(changeset)
27 end
28 end
29
30 defp render_show(conn, changeset) do
31 2 render(
32 conn,
33 "show.html",
34 title: "Sign up",
35 container: "container page page-xs signup",
36 changeset: changeset
37 )
38 end
39 end

lib/hexpm_web/controllers/sitemap_controller.ex

100
3
3
0
Line Hits Source
0 defmodule HexpmWeb.SitemapController do
1 use HexpmWeb, :controller
2
3 def main(conn, _params) do
4 conn
5 |> put_resp_content_type("text/xml")
6 |> put_resp_header("cache-control", "public, max-age=300")
7 1 |> render("packages_sitemap.xml", packages: Sitemaps.packages())
8 end
9
10 def docs(conn, _params) do
11 conn
12 |> put_resp_content_type("text/xml")
13 |> put_resp_header("cache-control", "public, max-age=300")
14 1 |> render("docs_sitemap.xml", packages: Sitemaps.packages_with_docs())
15 end
16
17 def preview(conn, _params) do
18 conn
19 |> put_resp_content_type("text/xml")
20 |> put_resp_header("cache-control", "public, max-age=300")
21 1 |> render("preview_sitemap.xml", packages: Sitemaps.packages_for_preview())
22 end
23 end

lib/hexpm_web/controllers/test_controller.ex

0
17
0
17
Line Hits Source
0 defmodule HexpmWeb.TestController do
1 use HexpmWeb, :controller
2
3 def names(conn, _params) do
4 Hexpm.Store.get(:repo_bucket, "names", [])
5 0 |> send_object(conn)
6 end
7
8 def versions(conn, _params) do
9 Hexpm.Store.get(:repo_bucket, "versions", [])
10 0 |> send_object(conn)
11 end
12
13 def package(conn, %{"repository" => repository, "package" => package}) do
14 0 Hexpm.Store.get(:repo_bucket, "repos/#{repository}/packages/#{package}", [])
15 0 |> send_object(conn)
16 end
17
18 def package(conn, %{"package" => package}) do
19 0 Hexpm.Store.get(:repo_bucket, "packages/#{package}", [])
20 0 |> send_object(conn)
21 end
22
23 def tarball(conn, %{"repository" => repository, "ball" => ball}) do
24 0 Hexpm.Store.get(:repo_bucket, "repos/#{repository}/tarballs/#{ball}", [])
25 0 |> send_object(conn)
26 end
27
28 def tarball(conn, %{"ball" => ball}) do
29 0 Hexpm.Store.get(:repo_bucket, "tarballs/#{ball}", [])
30 0 |> send_object(conn)
31 end
32
33 def repo(conn, params) do
34 0 {:ok, organization} =
35 0 Organizations.create(conn.assigns.current_user, params,
36 audit: {%User{}, "TEST", "127.0.0.1"}
37 )
38
39 organization
40 |> Ecto.Changeset.change(%{billing_active: true})
41 0 |> Hexpm.Repo.update!()
42
43 0 send_resp(conn, 204, "")
44 end
45
46 def installs_csv(conn, _params) do
47 0 send_resp(conn, 200, "")
48 end
49
50 0 defp send_object(nil, conn), do: send_resp(conn, 404, "")
51 0 defp send_object(obj, conn), do: send_resp(conn, 200, obj)
52 end

lib/hexpm_web/controllers/tfa_auth_controller.ex

100
13
23
0
Line Hits Source
0 defmodule HexpmWeb.TFAAuthController do
1 use HexpmWeb, :controller
2
3 plug :authenticate
4
5 1 def show(conn, _params), do: render_show(conn)
6
7 def create(conn, %{"code" => code}) do
8 2 %{"uid" => uid} = session = get_session(conn, "tfa_user_id")
9 2 user = Hexpm.Accounts.Users.get_by_id(uid)
10 2 secret = user.tfa.secret
11
12 2 if Hexpm.Accounts.TFA.token_valid?(secret, code) do
13 conn
14 |> delete_session("tfa_user_id")
15 1 |> HexpmWeb.LoginController.start_session(user, session["return"])
16 else
17 1 render_show_error(conn)
18 end
19 end
20
21 defp render_show(conn) do
22 2 render(
23 conn,
24 "show.html",
25 title: "Two Factor Authentication",
26 container: "container page page-xs login"
27 )
28 end
29
30 defp render_show_error(conn) do
31 1 msg = "The verification code you provided is incorrect. Please try again."
32
33 conn
34 |> put_flash(:error, msg)
35 1 |> render_show()
36 end
37
38 defp authenticate(conn, _opts) do
39 4 case get_session(conn, "tfa_user_id") do
40 nil ->
41 1 conn |> redirect(to: "/") |> halt()
42
43 _ ->
44 3 conn
45 end
46 end
47 end

lib/hexpm_web/controllers/tfa_recovery_controller.ex

92.9
14
22
1
Line Hits Source
0 defmodule HexpmWeb.TFARecoveryController do
1 use HexpmWeb, :controller
2
3 plug :authenticate
4
5 1 def show(conn, _params), do: render_show(conn)
6
7 def create(conn, %{"code" => code}) do
8 2 %{"uid" => uid} = session = get_session(conn, "tfa_user_id")
9 2 user = Hexpm.Accounts.Users.get_by_id(uid)
10
11 2 with true <- valid_code?(code),
12 1 {:ok, updated_user} <- Hexpm.Accounts.Users.tfa_recover(user, code) do
13 conn
14 |> delete_session("tfa_user_id")
15 1 |> HexpmWeb.LoginController.start_session(updated_user, session["return"])
16 else
17 _ ->
18 1 render_show_error(conn)
19 end
20 end
21
22 defp render_show(conn) do
23 2 render(
24 conn,
25 "show.html",
26 title: "Two Factor Recovery",
27 container: "container page page-xs login"
28 )
29 end
30
31 defp render_show_error(conn) do
32 1 msg = "The recovery code you provided is incorrect. Please try again."
33
34 conn
35 |> put_flash(:error, msg)
36 1 |> render_show()
37 end
38
39 defp authenticate(conn, _opts) do
40 3 case get_session(conn, "tfa_user_id") do
41 nil ->
42 0 conn |> redirect(to: "/") |> halt()
43
44 _ ->
45 3 conn
46 end
47 end
48
49 2 defp valid_code?(code), do: is_binary(code) and byte_size(code) == 19
50 end

lib/hexpm_web/controllers/user_controller.ex

76.5
17
52
4
Line Hits Source
0 defmodule HexpmWeb.UserController do
1 use HexpmWeb, :controller
2
3 def show(conn, %{"username" => username}) do
4 4 user =
5 Users.get_by_username(username, [
6 :emails,
7 :organization,
8 owned_packages: [:repository, :downloads]
9 ])
10
11 4 if user do
12 4 organization = user.organization
13
14 4 case conn.path_info do
15 ["users" | _] when not is_nil(organization) ->
16 0 redirect(conn, to: Router.user_path(user))
17
18 ["orgs" | _] when is_nil(organization) ->
19 0 redirect(conn, to: Router.user_path(user))
20
21 _ ->
22 4 show_user(conn, user)
23 end
24 else
25 0 not_found(conn)
26 end
27 end
28
29 defp show_user(conn, user) do
30 4 packages =
31 4 Packages.accessible_user_owned_packages(user, conn.assigns.current_user)
32 |> Packages.attach_versions()
33
34 4 downloads = Packages.packages_downloads_with_all_views(packages)
35
36 4 total_downloads =
37 0 Enum.reduce(downloads, 0, fn {_id, d}, acc -> acc + Map.get(d, "all", 0) end)
38
39 4 public_email = User.email(user, :public)
40 4 gravatar_email = User.email(user, :gravatar)
41
42 4 render(
43 conn,
44 "show.html",
45 4 title: user.username,
46 container: "container page user",
47 user: user,
48 packages: packages,
49 downloads: downloads,
50 total_downloads: total_downloads,
51 public_email: public_email,
52 gravatar_email: gravatar_email
53 )
54 end
55 end

lib/hexpm_web/controllers/version_controller.ex

100
12
25
0
Line Hits Source
0 defmodule HexpmWeb.VersionController do
1 use HexpmWeb, :controller
2
3 def index(conn, params) do
4 2 %{"repository" => repository, "name" => name} = params
5 2 organizations = Users.all_organizations(conn.assigns.current_user)
6 2 repositories = Enum.map(organizations, & &1.repository)
7
8 3 if repository in Enum.map(repositories, & &1.name) do
9 2 repository = Repositories.get(repository)
10 2 package = repository && Packages.get(repository, name)
11
12 # Should have access even though repository does not have active billing
13 2 if package do
14 2 releases = Releases.all(package)
15
16 2 if releases do
17 2 render(
18 conn,
19 "index.html",
20 2 title: "#{name} versions",
21 container: "container",
22 releases: releases,
23 package: package
24 )
25 end
26 end
27 2 end || not_found(conn)
28 end
29 end

lib/hexpm_web/elixir_format.ex

0
15
0
15
Line Hits Source
0 defmodule HexpmWeb.ElixirFormat do
1 def encode_to_iodata!(term) do
2 term
3 |> Hexpm.Utils.binarify()
4 0 |> inspect(limit: :infinity, binaries: :as_strings)
5 end
6
7 @spec decode(String.t()) :: term
8 0 def decode("") do
9 {:ok, nil}
10 end
11
12 def decode(string) do
13 0 case Code.string_to_quoted(string, existing_atoms_only: true) do
14 {:ok, ast} ->
15 0 safe_eval(ast)
16
17 0 _ ->
18 {:error, "malformed elixir"}
19 end
20 end
21
22 defp safe_eval(ast) do
23 0 if safe_term?(ast) do
24 0 result = Code.eval_quoted(ast) |> elem(0)
25 {:ok, result}
26 else
27 {:error, "unsafe elixir"}
28 end
29 end
30
31 defp safe_term?({func, _, terms}) when func in [:{}, :%{}] and is_list(terms) do
32 0 Enum.all?(terms, &safe_term?/1)
33 end
34
35 0 defp safe_term?(nil), do: true
36 0 defp safe_term?(term) when is_number(term), do: true
37 0 defp safe_term?(term) when is_binary(term), do: true
38 0 defp safe_term?(term) when is_boolean(term), do: true
39 0 defp safe_term?(term) when is_list(term), do: Enum.all?(term, &safe_term?/1)
40 0 defp safe_term?(term) when is_tuple(term), do: Enum.all?(Tuple.to_list(term), &safe_term?/1)
41 0 defp safe_term?(_), do: false
42 end

lib/hexpm_web/endpoint.ex

8.3
12
1
11
Line Hits Source
0 defmodule HexpmWeb.Endpoint do
1 use Phoenix.Endpoint, otp_app: :hexpm
2
3 plug HexpmWeb.Plugs.Forwarded
4
5 @session_options [
6 signing_salt: "NOcCmerj",
7 store: HexpmWeb.Session,
8 key: "_hexpm_key",
9 max_age: 60 * 60 * 24 * 30
10 ]
11
12 # Serve at "/" the static files from "priv/static" directory.
13 #
14 # You should set gzip to true if you are running phoenix.digest
15 # when deploying your static files in production.
16 plug Plug.Static,
17 at: "/",
18 from: :hexpm,
19 gzip: true,
20 only: ~w(css images js),
21 only_matching: ~w(favicon robots)
22
23 socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
24
25 # Code reloading can be explicitly enabled under the
26 # :code_reloader configuration of your endpoint.
27 if code_reloading? do
28 socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
29 plug Phoenix.LiveReloader
30 plug Phoenix.CodeReloader
31 plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hexpm
32 end
33
34 plug HexpmWeb.Plugs.Status
35
36 plug Phoenix.LiveDashboard.RequestLogger,
37 param_key: "request_logger",
38 cookie_key: "request_logger"
39
40 plug Plug.RequestId
41 plug Logster.Plugs.Logger, excludes: [:params]
42
43 plug Plug.Parsers,
44 parsers: [:urlencoded, :json, HexpmWeb.PlugParser],
45 pass: ["*/*"],
46 json_decoder: Jason
47
48 plug Plug.MethodOverride
49 plug Plug.Head
50 plug HexpmWeb.Plugs.Vary, ["accept-encoding"]
51
52 plug Plug.Session, @session_options
53
54 if Mix.env() == :prod do
55 plug Plug.SSL, rewrite_on: [:x_forwarded_proto]
56 end
57
58 plug HexpmWeb.Router
59
60 def init(_key, config) do
61 1 if config[:load_from_system_env] do
62 0 port = Application.get_env(:hexpm, :port)
63
64 0 case Integer.parse(port) do
65 {_int, ""} ->
66 0 host = Application.get_env(:hexpm, :host)
67 0 secret_key_base = Application.get_env(:hexpm, :secret_key_base)
68 0 live_view_signing_salt = Application.get_env(:hexpm, :live_view_signing_salt)
69
70 0 config = put_in(config[:http][:port], port)
71 0 config = put_in(config[:url][:host], host)
72 0 config = put_in(config[:secret_key_base], secret_key_base)
73 0 config = put_in(config[:live_view][:signing_salt], live_view_signing_salt)
74 0 config = put_in(config[:check_origin], ["//#{host}"])
75
76 {:ok, config}
77
78 0 :error ->
79 {:ok, config}
80 end
81 else
82 {:ok, config}
83 end
84 end
85 end

lib/hexpm_web/erlang_format.ex

50
6
3
3
Line Hits Source
0 defmodule HexpmWeb.ErlangFormat do
1 def encode_to_iodata!(term) do
2 term
3 |> Hexpm.Utils.binarify()
4 1 |> :erlang.term_to_binary()
5 end
6
7 @spec decode(binary) :: term
8 0 def decode("") do
9 {:ok, nil}
10 end
11
12 0 def decode(<<131, 80, _rest::binary>>) do
13 {:error, "bad binary_to_term"}
14 end
15
16 1 def decode(binary) do
17 1 term = Plug.Crypto.non_executable_binary_to_term(binary, [:safe])
18 {:ok, term}
19 rescue
20 0 ArgumentError ->
21 {:error, "bad binary_to_term"}
22 end
23 end

lib/hexpm_web/markdown_engine.ex

100
12
96
0
Line Hits Source
0 defmodule HexpmWeb.MarkdownEngine do
1 @behaviour Phoenix.Template.Engine
2
3 def compile(path, _name) do
4 3 html =
5 path
6 |> File.read!()
7 |> Earmark.as_html!(%Earmark.Options{gfm: true})
8 |> header_anchors("h3")
9 |> header_anchors("h4")
10
11 {:safe, html}
12 end
13
14 defp header_anchors(html, tag) do
15 6 icon =
16 HexpmWeb.ViewIcons.icon(:glyphicon, :link, class: "icon-link")
17 |> Phoenix.HTML.safe_to_string()
18
19 6 Regex.replace(~r"<#{tag}>\n?(.*)<\/#{tag}>", html, fn _, header ->
20 9 anchor =
21 header
22 |> String.downcase()
23 |> dashify()
24 |> only_alphanumeric()
25
26 9 """
27 9 <#{tag} id="#{anchor}" class="section-heading">
28 9 <a href="##{anchor}" class="hover-link">
29 9 #{icon}
30 </a>
31 9 #{header}
32 9 </#{tag}>
33 """
34 end)
35 end
36
37 defp dashify(string) do
38 9 String.replace(string, " ", "-")
39 end
40
41 defp only_alphanumeric(string) do
42 9 String.replace(string, ~r"([^a-zA-Z0-9\-])", "")
43 end
44 end

lib/hexpm_web/plug_parser.ex

46.7
15
64
8
Line Hits Source
0 defmodule HexpmWeb.PlugParser do
1 alias Plug.Conn
2
3 @formats ~w(elixir erlang json)
4
5 def parse(%Conn{} = conn, "application", "vnd.hex+" <> format, _headers, opts)
6 when format in @formats do
7 1 decoder = get_decoder(format, opts)
8
9 conn
10 |> Conn.read_body(opts)
11 1 |> decode(decoder)
12 end
13
14 58 def parse(conn, _type, _subtype, _headers, _opts) do
15 {:next, conn}
16 end
17
18 defp decode({:more, _, conn}, _decoder) do
19 0 {:error, :too_large, conn}
20 end
21
22 defp decode({:error, :timeout}, _decoder) do
23 0 raise Plug.TimeoutError
24 end
25
26 defp decode({:error, _}, _decoder) do
27 0 raise Plug.BadRequestError
28 end
29
30 defp decode({:ok, "", conn}, _decoder) do
31 0 {:ok, %{}, conn}
32 end
33
34 defp decode({:ok, body, conn}, decoder) do
35 1 case decoder.decode(body) do
36 {:ok, terms} when is_map(terms) ->
37 1 {:ok, terms, conn}
38
39 {:ok, terms} ->
40 0 {:ok, %{"_json" => terms}, conn}
41
42 {:error, reason} ->
43 0 raise Plug.BadRequestError, message: reason
44 end
45 end
46
47 defp get_decoder(format, opts) do
48 1 case format do
49 0 "elixir" -> HexpmWeb.ElixirFormat
50 1 "erlang" -> HexpmWeb.ErlangFormat
51 0 "json" -> Keyword.fetch!(opts, :json_decoder)
52 end
53 end
54 end

lib/hexpm_web/plugs.ex

82.9
41
7176
7
Line Hits Source
0 defmodule HexpmWeb.Plugs do
1 import Plug.Conn, except: [read_body: 1]
2
3 alias Hexpm.Accounts.Users
4 alias HexpmWeb.ControllerHelpers
5
6 # Max filesize: ~10mb
7 # Min upload speed: ~10kb/s
8 # Read 100kb every 10s
9 @read_body_opts [
10 length: 10_000_000,
11 read_length: 100_000,
12 read_timeout: 10_000
13 ]
14
15 def validate_url(conn, _opts) do
16 509 if String.contains?(conn.request_path <> conn.query_string, "%00") do
17 conn
18 |> ControllerHelpers.render_error(400)
19 0 |> halt()
20 else
21 509 conn
22 end
23 end
24
25 def fetch_body(conn, _opts) do
26 56 {conn, body} = read_body(conn)
27 56 put_in(conn.params["body"], body)
28 end
29
30 def read_body_finally(conn, _opts) do
31 58 register_before_send(conn, fn conn ->
32 58 if conn.status in 200..399 do
33 32 conn
34 else
35 # If we respond with an unsuccessful error code assume we did not read
36 # body. Read the full body to avoid closing the connection too early,
37 # works around getting H13/H18 errors on Heroku.
38 26 case read_body(conn, @read_body_opts) do
39 26 {:ok, _body, conn} -> conn
40 0 _ -> conn
41 end
42 end
43 end)
44 end
45
46 defp read_body(conn) do
47 56 case read_body(conn, @read_body_opts) do
48 56 {:ok, body, conn} ->
49 {conn, body}
50
51 {:error, :timeout} ->
52 0 raise Plug.TimeoutError
53
54 {:error, _} ->
55 0 raise Plug.BadRequestError
56
57 {:more, _, _} ->
58 0 raise Plug.Parsers.RequestTooLargeError
59 end
60 end
61
62 def user_agent(conn, opts) do
63 519 case get_req_header(conn, "user-agent") do
64 [value | _] ->
65 0 assign(conn, :user_agent, value)
66
67 [] ->
68 519 if Keyword.get(opts, :required, true) && Application.get_env(:hexpm, :user_agent_req) do
69 0 ControllerHelpers.render_error(conn, 400, message: "User-Agent header is required")
70 else
71 519 assign(conn, :user_agent, "missing")
72 end
73 end
74 end
75
76 def default_repository(conn, _opts) do
77 508 param_set? = Map.has_key?(conn.params, "repository")
78
79 508 case conn.path_info do
80 8 ["api", "packages"] -> conn
81 24 ["api", "publish"] when not param_set? -> put_in(conn.params["repository"], "hexpm")
82 59 ["api", "packages" | _] when not param_set? -> put_in(conn.params["repository"], "hexpm")
83 22 ["packages" | _] when not param_set? -> put_in(conn.params["repository"], "hexpm")
84 395 _ -> conn
85 end
86 end
87
88 def login(conn, _opts) do
89 199 user_id = get_session(conn, "user_id")
90 199 user = user_id && Users.get_by_id(user_id, [:emails, organizations: :repository])
91 199 conn = assign(conn, :current_organization, nil)
92
93 199 if user do
94 130 assign(conn, :current_user, user)
95 else
96 69 assign(conn, :current_user, nil)
97 end
98 end
99
100 def disable_deactivated(conn, _opts) do
101 509 if conn.assigns.current_user && conn.assigns.current_user.deactivated_at do
102 conn
103 |> ControllerHelpers.render_error(400)
104 1 |> halt()
105 else
106 508 conn
107 end
108 end
109
110 def authenticate(conn, _opts) do
111 320 case HexpmWeb.AuthHelpers.authenticate(conn) do
112 {:ok, %{key: key, user: user, organization: organization, email: email, source: source}} ->
113 conn
114 |> assign(:key, key)
115 |> assign(:current_user, user)
116 |> assign(:current_organization, organization)
117 |> assign(:email, email)
118 237 |> assign(:auth_source, source)
119
120 {:error, :missing} ->
121 conn
122 |> assign(:key, nil)
123 |> assign(:current_user, nil)
124 |> assign(:current_organization, nil)
125 |> assign(:email, nil)
126 73 |> assign(:auth_source, nil)
127
128 {:error, _} = error ->
129 10 HexpmWeb.AuthHelpers.error(conn, error)
130 end
131 end
132 end

lib/hexpm_web/plugs/attack.ex

87.3
55
21283
7
Line Hits Source
0 # TODO: Don't rate limit conditional requests that return 304 Not Modified
1
2 defmodule HexpmWeb.Plugs.Attack do
3 use PlugAttack
4 import HexpmWeb.ControllerHelpers
5 import Plug.Conn
6 alias Hexpm.BlockAddress
7 alias HexpmWeb.RateLimitPubSub
8
9 @storage {PlugAttack.Storage.Ets, HexpmWeb.Plugs.Attack.Storage}
10
11 rule "allow local", conn do
12 1129 allow(conn.remote_ip == {127, 0, 0, 1})
13 end
14
15 rule "allow addresses", conn do
16 619 BlockAddress.try_reload()
17 619 allow(BlockAddress.allowed?(ip_string(conn.remote_ip)))
18 end
19
20 rule "block addresses", conn do
21 618 BlockAddress.try_reload()
22 618 block(BlockAddress.blocked?(ip_string(conn.remote_ip)))
23 end
24
25 rule "user throttle", conn do
26 612 user = conn.assigns[:current_user]
27
28 612 if api?(conn) && user do
29 503 user_throttle(user.id)
30 end
31 end
32
33 rule "organization throttle", conn do
34 109 organization = conn.assigns[:current_organization]
35
36 109 if api?(conn) && organization do
37 0 organization_throttle(organization.id)
38 end
39 end
40
41 rule "ip throttle", conn do
42 109 if api?(conn) do
43 109 ip_throttle(conn.remote_ip)
44 end
45 end
46
47 def allow_action(conn, {:throttle, data}, _opts) do
48 610 add_throttling_headers(conn, data)
49 end
50
51 def allow_action(conn, _data, _opts) do
52 511 conn
53 end
54
55 def block_action(conn, {:throttle, data}, _opts) do
56 conn
57 |> add_throttling_headers(data)
58 2 |> render_error(429, message: "API rate limit exceeded for #{throttled_user(conn)}")
59 end
60
61 def block_action(conn, _data, _opts) do
62 6 render_error(conn, 403, message: "Blocked")
63 end
64
65 defp add_throttling_headers(conn, data) do
66 # The expires_at value is a unix time in milliseconds, we want to return one
67 # in seconds
68 612 reset = div(data[:expires_at], 1_000)
69
70 conn
71 |> put_resp_header("x-ratelimit-limit", Integer.to_string(data[:limit]))
72 |> put_resp_header("x-ratelimit-remaining", Integer.to_string(data[:remaining]))
73 612 |> put_resp_header("x-ratelimit-reset", Integer.to_string(reset))
74 end
75
76 defp throttled_user(conn) do
77 2 cond do
78 2 user = conn.assigns.current_user ->
79 1 "user #{user.id}"
80
81 1 organization = conn.assigns.current_organization ->
82 0 "organization #{organization.id}"
83
84 1 true ->
85 1 "IP #{ip_string(conn.remote_ip)}"
86 end
87 end
88
89 defp ip_string({a, b, c, d}) do
90 1238 "#{a}.#{b}.#{c}.#{d}"
91 end
92
93 def user_throttle(user_id, opts \\ []) do
94 505 key = {:user, user_id}
95 505 time = opts[:time] || System.system_time(:millisecond)
96 505 unless opts[:time], do: RateLimitPubSub.broadcast(key, time)
97
98 505 timed_throttle(
99 key,
100 time: time,
101 storage: @storage,
102 limit: 500,
103 period: 60_000
104 )
105 end
106
107 def organization_throttle(organization_id, opts \\ []) do
108 0 key = {:organization, organization_id}
109 0 time = opts[:time] || System.system_time(:millisecond)
110 0 unless opts[:time], do: RateLimitPubSub.broadcast(key, time)
111
112 0 timed_throttle(
113 key,
114 time: time,
115 storage: @storage,
116 limit: 500,
117 period: 60_000
118 )
119 end
120
121 def ip_throttle(ip, opts \\ []) do
122 111 key = {:ip, ip}
123 111 time = opts[:time] || System.system_time(:millisecond)
124 111 unless opts[:time], do: RateLimitPubSub.broadcast(key, time)
125
126 111 timed_throttle(
127 key,
128 time: time,
129 storage: @storage,
130 limit: 100,
131 period: 60_000
132 )
133 end
134
135 # From https://github.com/michalmuskala/plug_attack/blob/812ff857d0958f1a00a711273887d7187ae80a23/lib/rule.ex#L62
136 # Adding an option for `now`
137 defp timed_throttle(key, opts) do
138 616 if key do
139 616 do_throttle(key, opts)
140 else
141 nil
142 end
143 end
144
145 defp do_throttle(key, opts) do
146 616 storage = Keyword.fetch!(opts, :storage)
147 616 limit = Keyword.fetch!(opts, :limit)
148 616 period = Keyword.fetch!(opts, :period)
149 616 now = Keyword.fetch!(opts, :time)
150
151 616 expires_at = expires_at(now, period)
152 616 count = do_throttle(storage, key, now, period, expires_at)
153 616 rem = limit - count
154 616 data = [period: period, expires_at: expires_at, limit: limit, remaining: max(rem, 0)]
155 616 {if(rem >= 0, do: :allow, else: :block), {:throttle, data}}
156 end
157
158 616 defp expires_at(now, period), do: (div(now, period) + 1) * period
159
160 defp do_throttle({mod, opts}, key, now, period, expires_at) do
161 616 full_key = {:throttle, key, div(now, period)}
162 616 mod.increment(opts, full_key, 1, expires_at)
163 end
164
165 830 defp api?(%Plug.Conn{request_path: "/api/" <> _}), do: true
166 0 defp api?(%Plug.Conn{}), do: false
167 end

lib/hexpm_web/plugs/dashboard_auth.ex

0
2
0
2
Line Hits Source
0 defmodule HexpmWeb.Plugs.DashboardAuth do
1 @moduledoc """
2 Basic Auth for liveview dashboard
3 """
4
5 import Plug.BasicAuth
6
7 0 def init(_opts), do: :ok
8
9 def call(conn, _opts) do
10 0 basic_auth(conn,
11 username: Application.get_env(:hexpm, :dashboard_user),
12 password: Application.get_env(:hexpm, :dashboard_password)
13 )
14 end
15 end

lib/hexpm_web/plugs/forwarded.ex

30
10
1599
7
Line Hits Source
0 defmodule HexpmWeb.Plugs.Forwarded do
1 import Plug.Conn
2 require Logger
3
4 0 def init(opts), do: opts
5
6 def call(conn, _opts) do
7 533 ip = get_req_header(conn, "x-forwarded-for")
8 533 %{conn | remote_ip: ip(ip) || conn.remote_ip}
9 end
10
11 defp ip([ip | _]) do
12 # According to https://cloud.google.com/load-balancing/docs/https/#components
13 0 ip = String.split(ip, ",") |> Enum.at(-2)
14
15 0 if ip do
16 0 ip = String.trim(ip)
17
18 0 case :inet.parse_address(to_charlist(ip)) do
19 {:ok, parsed_ip} ->
20 0 parsed_ip
21
22 {:error, _} ->
23 0 Logger.warn("Invalid IP: #{inspect(ip)}")
24 nil
25 end
26 end
27 end
28
29 533 defp ip(_), do: nil
30 end

lib/hexpm_web/plugs/status.ex

33.3
3
533
2
Line Hits Source
0 defmodule HexpmWeb.Plugs.Status do
1 import Plug.Conn
2 alias Plug.Conn
3
4 0 def init(opts), do: opts
5
6 def call(%Conn{path_info: ["status"]} = conn, _opts) do
7 conn
8 |> send_resp(200, "")
9 0 |> halt()
10 end
11
12 def call(conn, _opts) do
13 533 conn
14 end
15 end

lib/hexpm_web/plugs/vary.ex

80
5
2132
1
Line Hits Source
0 defmodule HexpmWeb.Plugs.Vary do
1 import Plug.Conn
2
3 0 def init(opts), do: opts
4
5 def call(conn, vary) do
6 533 register_before_send(conn, fn conn ->
7 533 original_vary = get_resp_header(conn, "vary")
8 533 vary = Enum.join(original_vary ++ vary, ", ")
9 533 put_resp_header(conn, "vary", vary)
10 end)
11 end
12 end

lib/hexpm_web/rate_limit_pub_sub.ex

85.7
7
1230
1
Line Hits Source
0 defmodule HexpmWeb.RateLimitPubSub do
1 use GenServer
2 alias HexpmWeb.Plugs.Attack
3
4 def start_link(_) do
5 1 GenServer.start_link(__MODULE__, [], name: __MODULE__)
6 end
7
8 def broadcast(key, time) do
9 612 server = GenServer.whereis(__MODULE__)
10 612 Phoenix.PubSub.broadcast_from!(Hexpm.PubSub, server, "ratelimit", {:throttle, key, time})
11 end
12
13 def init([]) do
14 1 Phoenix.PubSub.subscribe(Hexpm.PubSub, "ratelimit")
15 {:ok, []}
16 end
17
18 def handle_info({:throttle, {:user, user_id}, time}, []) do
19 2 Attack.user_throttle(user_id, time: time)
20 {:noreply, []}
21 end
22
23 def handle_info({:throttle, {:organization, organization_id}, time}, []) do
24 0 Attack.organization_throttle(organization_id, time: time)
25 {:noreply, []}
26 end
27
28 def handle_info({:throttle, {:ip, ip}, time}, []) do
29 2 Attack.ip_throttle(ip, time: time)
30 {:noreply, []}
31 end
32 end

lib/hexpm_web/router.ex

71.0
183
993
53
Line Hits Source
0 defmodule HexpmWeb.Router do
1 use HexpmWeb, :router
2 use Plug.ErrorHandler
3 import Phoenix.LiveDashboard.Router
4 alias Hexpm.Accounts.{Organization, User}
5
6 @accepted_formats ~w(json elixir erlang)
7
8 199 pipeline :browser do
9 plug :accepts, ["html"]
10 plug :fetch_session
11 plug :fetch_flash
12 plug :protect_from_forgery
13 plug :put_secure_browser_headers
14 plug :user_agent, required: false
15 plug :validate_url
16 plug HexpmWeb.Plugs.Attack
17 plug :login
18 plug :disable_deactivated
19 plug :default_repository
20 end
21
22 58 pipeline :upload do
23 plug :read_body_finally
24 plug :accepts, @accepted_formats
25 plug :user_agent
26 plug :authenticate
27 plug :disable_deactivated
28 plug :validate_url
29 plug HexpmWeb.Plugs.Attack
30 plug :fetch_body
31 plug :default_repository
32 end
33
34 262 pipeline :api do
35 plug :accepts, @accepted_formats
36 plug :user_agent
37 plug :authenticate
38 plug :disable_deactivated
39 plug :validate_url
40 plug HexpmWeb.Plugs.Attack
41 plug Corsica, origins: "*", allow_methods: ["HEAD", "GET"]
42 plug :default_repository
43 end
44
45 0 pipeline :admin do
46 plug HexpmWeb.Plugs.DashboardAuth
47 end
48
49 if Mix.env() == :dev do
50 forward "/sent_emails", Bamboo.SentEmailViewerPlug
51 end
52
53 scope "/", HexpmWeb do
54 pipe_through :browser
55
56 3 get "/", PageController, :index
57 1 get "/about", PageController, :about
58 1 get "/pricing", PageController, :pricing
59 1 get "/sponsors", PageController, :sponsors
60
61 1 get "/login", LoginController, :show
62 12 post "/login", LoginController, :create
63 1 post "/logout", LoginController, :delete
64
65 2 get "/two_factor_auth", TFAAuthController, :show
66 2 post "/two_factor_auth", TFAAuthController, :create
67
68 1 get "/two_factor_auth/recovery", TFARecoveryController, :show
69 2 post "/two_factor_auth/recovery", TFARecoveryController, :create
70
71 1 get "/signup", SignupController, :show
72 2 post "/signup", SignupController, :create
73
74 2 get "/password/new", PasswordController, :show
75 3 post "/password/new", PasswordController, :update
76
77 1 get "/password/reset", PasswordResetController, :show
78 2 post "/password/reset", PasswordResetController, :create
79
80 6 get "/email/verify", EmailVerificationController, :verify
81 1 get "/email/verification", EmailVerificationController, :show
82 3 post "/email/verification", EmailVerificationController, :create
83
84 2 get "/dashboard", DashboardController, :index
85
86 4 get "/users/:username", UserController, :show
87
88 0 get "/orgs/:username", UserController, :show
89
90 0 get "/docs", DocsController, :index
91 0 get "/docs/usage", DocsController, :usage
92 0 get "/docs/publish", DocsController, :publish
93 0 get "/docs/tasks", DocsController, :tasks
94 0 get "/docs/rebar3_usage", DocsController, :rebar3_usage
95 0 get "/docs/rebar3_publish", DocsController, :rebar3_publish
96 0 get "/docs/rebar3_private", DocsController, :rebar3_private
97 0 get "/docs/rebar3_tasks", DocsController, :rebar3_tasks
98 0 get "/docs/private", DocsController, :private
99 0 get "/docs/faq", DocsController, :faq
100 0 get "/docs/mirrors", DocsController, :mirrors
101 0 get "/docs/public_keys", DocsController, :public_keys
102 0 get "/docs/self_hosting", DocsController, :self_hosting
103
104 1 get "/policies/codeofconduct", PolicyController, :coc
105 1 get "/policies/privacy", PolicyController, :privacy
106 1 get "/policies/termsofservice", PolicyController, :tos
107 1 get "/policies/copyright", PolicyController, :copyright
108 0 get "/policies/dispute", PolicyController, :dispute
109
110 1 get "/packages/:name/versions", VersionController, :index
111 1 get "/packages/:repository/:name/versions", VersionController, :index
112
113 8 get "/packages", PackageController, :index
114 5 get "/packages/:name", PackageController, :show
115 2 get "/packages/:name/audit_logs", PackageController, :audit_logs
116 6 get "/packages/:name/:version", PackageController, :show
117 1 get "/packages/:repository/:name/audit_logs", PackageController, :audit_logs
118 4 get "/packages/:repository/:name/:version", PackageController, :show
119
120 0 get "/blog", BlogController, :index
121 0 get "/blog/:slug", BlogController, :show
122
123 2 get "/l/:short_code", ShortURLController, :show
124
125 if Application.compile_env!(:hexpm, [:features, :package_reports]) do
126 1 get "/reports", PackageReportController, :index
127 0 get "/reports/new", PackageReportController, :new
128 0 post "/reports/create", PackageReportController, :create
129
130 21 get "/reports/:id", PackageReportController, :show
131 0 post "/reports/:id/accept", PackageReportController, :accept_report
132 0 post "/reports/:id/reject", PackageReportController, :reject_report
133 1 post "/reports/:id/solve", PackageReportController, :solve_report
134 0 post "/reports/:id/unresolve", PackageReportController, :unresolve_report
135 0 post "/reports/:id/comment", PackageReportController, :new_comment
136 end
137 end
138
139 scope "/dashboard", HexpmWeb.Dashboard do
140 pipe_through :browser
141
142 2 get "/profile", ProfileController, :index
143 5 post "/profile", ProfileController, :update
144
145 2 get "/password", PasswordController, :index, as: :dashboard_password
146 4 post "/password", PasswordController, :update, as: :dashboard_password
147
148 2 get "/security", SecurityController, :index, as: :dashboard_security
149 1 post "/security/enable_tfa", SecurityController, :enable_tfa, as: :dashboard_security
150 1 post "/security/disable_tfa", SecurityController, :disable_tfa, as: :dashboard_security
151
152 1 post "/security/rotate_recovery_codes", SecurityController, :rotate_recovery_codes,
153 as: :dashboard_security
154
155 1 post "/security/reset_auth_app", SecurityController, :reset_auth_app, as: :dashboard_security
156
157 2 get "/two_factor_auth/setup", TFAAuthSetupController, :index, as: :dashboard_tfa_setup
158 2 post "/two_factor_auth/setup", TFAAuthSetupController, :create, as: :dashboard_tfa_setup
159
160 2 get "/email", EmailController, :index
161 3 post "/email", EmailController, :create
162 2 delete "/email", EmailController, :delete
163 3 post "/email/primary", EmailController, :primary
164 2 post "/email/public", EmailController, :public
165 1 post "/email/resend", EmailController, :resend_verify
166 3 post "/email/gravatar", EmailController, :gravatar
167
168 0 get "/repos", OrganizationController, :redirect_repo
169 0 get "/repos/*glob", OrganizationController, :redirect_repo
170 5 get "/orgs/:dashboard_org", OrganizationController, :show
171 4 post "/orgs/:dashboard_org", OrganizationController, :update
172 1 post "/orgs/:dashboard_org/leave", OrganizationController, :leave
173 2 post "/orgs/:dashboard_org/billing-token", OrganizationController, :billing_token
174 3 post "/orgs/:dashboard_org/cancel-billing", OrganizationController, :cancel_billing
175 2 post "/orgs/:dashboard_org/update-billing", OrganizationController, :update_billing
176 2 post "/orgs/:dashboard_org/create-billing", OrganizationController, :create_billing
177 3 post "/orgs/:dashboard_org/add-seats", OrganizationController, :add_seats
178 3 post "/orgs/:dashboard_org/remove-seats", OrganizationController, :remove_seats
179 2 post "/orgs/:dashboard_org/change-plan", OrganizationController, :change_plan
180 1 post "/orgs/:dashboard_org/keys", OrganizationController, :create_key
181 2 delete "/orgs/:dashboard_org/keys", OrganizationController, :delete_key
182 1 get "/orgs/:dashboard_org/invoices/:id", OrganizationController, :show_invoice
183 3 post "/orgs/:dashboard_org/invoices/:id/pay", OrganizationController, :pay_invoice
184 3 post "/orgs/:dashboard_org/profile", OrganizationController, :update_profile
185 1 get "/orgs", OrganizationController, :new
186 2 post "/orgs", OrganizationController, :create
187
188 2 get "/keys", KeyController, :index
189 2 delete "/keys", KeyController, :delete
190 1 post "/keys", KeyController, :create
191
192 4 get "/audit_logs", AuditLogController, :index
193 end
194
195 scope "/", HexpmWeb do
196 1 get "/sitemap.xml", SitemapController, :main
197 1 get "/docs_sitemap.xml", SitemapController, :docs
198 1 get "/preview_sitemap.xml", SitemapController, :preview
199 1 get "/hexsearch.xml", OpenSearchController, :opensearch
200 9 get "/installs/hex.ez", InstallController, :archive
201 1 get "/feeds/blog.xml", FeedsController, :blog
202 end
203
204 scope "/api", HexpmWeb.API, as: :api do
205 pipe_through :upload
206
207 for prefix <- ["/", "/repos/:repository"] do
208 scope prefix do
209 26 post "/publish", ReleaseController, :publish
210 9 post "/packages/:name/releases", ReleaseController, :create
211 6 post "/packages/:name/releases/:version/docs", DocsController, :create
212 end
213 end
214 end
215
216 scope "/api", HexpmWeb.API, as: :api do
217 pipe_through :api
218
219 1 get "/", IndexController, :index
220
221 4 post "/users", UserController, :create
222 3 get "/users/me", UserController, :me
223 3 get "/users/me/audit_logs", UserController, :audit_logs
224 5 get "/users/:name", UserController, :show
225 # NOTE: Deprecated (2018-05-21)
226 2 get "/users/:name/test", UserController, :test
227 2 post "/users/:name/reset", UserController, :reset
228
229 2 get "/orgs", OrganizationController, :index
230 5 get "/orgs/:organization", OrganizationController, :show
231 4 post "/orgs/:organization", OrganizationController, :update
232 3 get "/orgs/:organization/audit_logs", OrganizationController, :audit_logs
233
234 3 get "/orgs/:organization/members", OrganizationUserController, :index
235 7 post "/orgs/:organization/members", OrganizationUserController, :create
236 3 get "/orgs/:organization/members/:name", OrganizationUserController, :show
237 5 post "/orgs/:organization/members/:name", OrganizationUserController, :update
238 5 delete "/orgs/:organization/members/:name", OrganizationUserController, :delete
239
240 2 get "/repos", RepositoryController, :index
241 4 get "/repos/:repository", RepositoryController, :show
242
243 for prefix <- ["/", "/repos/:repository"] do
244 scope prefix do
245 8 get "/packages", PackageController, :index
246 5 get "/packages/:name", PackageController, :show
247 1 get "/packages/:name/audit_logs", PackageController, :audit_logs
248
249 10 get "/packages/:name/releases/:version", ReleaseController, :show
250 5 delete "/packages/:name/releases/:version", ReleaseController, :delete
251
252 9 post "/packages/:name/releases/:version/retire", RetirementController, :create
253 9 delete "/packages/:name/releases/:version/retire", RetirementController, :delete
254
255 3 get "/packages/:name/releases/:version/docs", DocsController, :show
256 2 delete "/packages/:name/releases/:version/docs", DocsController, :delete
257
258 12 get "/packages/:name/owners", OwnerController, :index
259 5 get "/packages/:name/owners/:username", OwnerController, :show
260 13 put "/packages/:name/owners/:username", OwnerController, :create
261 9 delete "/packages/:name/owners/:username", OwnerController, :delete
262 end
263 end
264
265 for prefix <- ["/", "/orgs/:organization"] do
266 scope prefix do
267 7 get "/keys", KeyController, :index
268 2 get "/keys/:name", KeyController, :show
269 6 post "/keys", KeyController, :create
270 1 delete "/keys", KeyController, :delete_all
271 2 delete "/keys/:name", KeyController, :delete
272 end
273 end
274
275 2 post "/short_url", ShortURLController, :create
276 43 get "/auth", AuthController, :show
277 end
278
279 if Mix.env() in [:dev, :test, :hex] do
280 scope "/repo", HexpmWeb do
281 0 get "/names", TestController, :names
282 0 get "/versions", TestController, :versions
283 0 get "/installs/hex-1.x.csv", TestController, :installs_csv
284
285 for prefix <- ["/", "/repos/:repository"] do
286 scope prefix do
287 0 get "/packages/:package", TestController, :package
288 0 get "/tarballs/:ball", TestController, :tarball
289 end
290 end
291 end
292
293 scope "/api", HexpmWeb do
294 pipe_through :api
295
296 0 post "/repo", TestController, :repo
297 end
298 end
299
300 scope "/" do
301 pipe_through [:browser, :admin]
302 0 live_dashboard("/db", metrics: HexpmWeb.Telemetry)
303 end
304
305 def user_path(%User{organization: nil} = user) do
306 1 Routes.user_path(Endpoint, :show, user)
307 end
308
309 def user_path(%User{organization: %Organization{} = organization}) do
310 # Work around for path helpers with duplicate routes
311 0 "/orgs/#{organization.name}"
312 end
313
314 defp handle_errors(conn, %{kind: kind, reason: reason, stack: stacktrace}) do
315 1 if report?(kind, reason) do
316 0 conn = maybe_fetch_params(conn)
317 0 url = "#{conn.scheme}://#{conn.host}:#{conn.port}#{conn.request_path}"
318 0 user_ip = conn.remote_ip |> :inet.ntoa() |> List.to_string()
319 0 headers = conn.req_headers |> Map.new() |> filter_headers()
320 0 params = filter_params(conn.params)
321 0 endpoint_url = HexpmWeb.Endpoint.config(:url)
322
323 0 conn_data = %{
324 "request" => %{
325 "url" => url,
326 "user_ip" => user_ip,
327 "headers" => headers,
328 "params" => params,
329 0 "method" => conn.method
330 },
331 "server" => %{
332 "host" => endpoint_url[:host],
333 "root" => endpoint_url[:path]
334 }
335 }
336
337 0 Rollbax.report(kind, reason, stacktrace, %{}, conn_data)
338 end
339 end
340
341 1 defp report?(:error, %Hexpm.WriteInReadOnlyMode{}), do: false
342 0 defp report?(:error, exception), do: Plug.Exception.status(exception) == 500
343 0 defp report?(_kind, _reason), do: true
344
345 defp maybe_fetch_params(conn) do
346 0 try do
347 0 Plug.Conn.fetch_query_params(conn)
348 rescue
349 _ ->
350 0 %{conn | params: "[UNFETCHED]"}
351 end
352 end
353
354 @filter_headers ~w(authorization)
355
356 defp filter_headers(headers) do
357 0 Map.drop(headers, @filter_headers)
358 end
359
360 @filter_params ~w(body password password_confirmation)
361
362 defp filter_params(params) when is_map(params) do
363 0 Map.new(params, fn {key, value} ->
364 0 if key in @filter_params do
365 {key, "[FILTERED]"}
366 else
367 {key, filter_params(value)}
368 end
369 end)
370 end
371
372 defp filter_params(params) when is_list(params) do
373 0 Enum.map(params, &filter_params/1)
374 end
375
376 defp filter_params(other) do
377 0 other
378 end
379 end

lib/hexpm_web/session.ex

88.9
18
1156
2
Line Hits Source
0 defmodule HexpmWeb.Session do
1 alias Hexpm.Accounts.Session
2 alias Hexpm.Repo
3
4 @behaviour Plug.Session.Store
5
6 0 def init(_opts) do
7 :ok
8 end
9
10 def get(_conn, cookie, _opts) do
11 4 with {id, "++" <> token} <- Integer.parse(cookie),
12 4 {:ok, token} <- Base.url_decode64(token),
13 4 session = Repo.get(Session, id),
14 4 true <- session && Plug.Crypto.secure_compare(token, session.token) do
15 4 {{id, token}, session.data}
16 else
17 _ ->
18 {nil, %{}}
19 end
20 end
21
22 def put(_conn, nil, data, _opts) do
23 187 session = Session.build(data)
24
25 187 session =
26 if Repo.write_mode?() do
27 187 Repo.insert!(session)
28 else
29 0 Ecto.Changeset.apply_changes(session)
30 end
31
32 187 build_cookie(session)
33 end
34
35 def put(_conn, {id, token}, data, _opts) do
36 3 if Repo.write_mode?() do
37 3 Repo.update_all(
38 Session.by_id(id),
39 set: [
40 data: data,
41 updated_at: DateTime.utc_now()
42 ]
43 )
44 end
45
46 3 build_cookie(id, token)
47 end
48
49 def delete(_conn, {id, _token}, _opts) do
50 1 if Repo.write_mode?() do
51 1 Repo.delete_all(Session.by_id(id))
52 end
53
54 :ok
55 end
56
57 defp build_cookie(session) do
58 187 build_cookie(session.id, session.token)
59 end
60
61 defp build_cookie(id, token) do
62 190 "#{id}++#{Base.url_encode64(token)}"
63 end
64 end

lib/hexpm_web/stale.ex

42.9
14
784
8
Line Hits Source
0 defprotocol HexpmWeb.Stale do
1 95 def etag(schema)
2 47 def last_modified(schema)
3 end
4
5 defimpl HexpmWeb.Stale, for: Atom do
6 0 def etag(nil), do: nil
7
8 # This is not a good solution because we don't know when a missing
9 # association was modified but this is the best we have for now
10 0 def last_modified(nil), do: ~N[0000-01-01 00:00:00]
11 end
12
13 defimpl HexpmWeb.Stale, for: Any do
14 defmacro __deriving__(module, _struct, opts) do
15 0 etag_keys = Keyword.get(opts, :etag, [:__struct__, :id, :updated_at])
16 0 last_modified_key = Keyword.get(opts, :last_modified, :updated_at)
17 0 assocs = Keyword.get(opts, :assocs, [])
18
19 quote do
20 defimpl HexpmWeb.Stale, for: unquote(module) do
21 alias HexpmWeb.Stale
22 alias HexpmWeb.Stale.Any
23
24 def etag(schema) do
25 assocs = unquote(assocs)
26 etag_keys = unquote(etag_keys)
27 [Map.take(schema, etag_keys), Any.recurse_fields(schema, assocs, &Stale.etag/1)]
28 end
29
30 def last_modified(schema) do
31 assocs = unquote(assocs)
32 last_modified_key = unquote(last_modified_key)
33 last_modified = fetch_last_modified(schema, last_modified_key)
34 [last_modified, Any.recurse_fields(schema, assocs, &Stale.last_modified/1)]
35 end
36
37 defp fetch_last_modified(_schema, nil), do: ~N[0000-01-01 00:00:00]
38 defp fetch_last_modified(schema, key), do: Map.fetch!(schema, key)
39 end
40 end
41 end
42
43 0 def etag(_), do: raise("not implemented")
44 0 def last_modified(_), do: raise("not implemented")
45
46 def recurse_fields(schema, keys, fun) do
47 142 Enum.map(keys, fn key ->
48 Map.fetch!(schema, key)
49 250 |> recurse_field(fun)
50 end)
51 end
52
53 152 defp recurse_field(%Ecto.Association.NotLoaded{}, _fun), do: []
54 98 defp recurse_field(schemas, fun) when is_list(schemas), do: Enum.map(schemas, fun)
55 0 defp recurse_field(schema, fun), do: fun.(schema)
56 end

lib/hexpm_web/telemetry.ex

80
5
4
1
Line Hits Source
0 defmodule HexpmWeb.Telemetry do
1 use Supervisor
2 import Telemetry.Metrics
3
4 def start_link(arg) do
5 1 Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
6 end
7
8 @impl true
9 def init(_arg) do
10 1 children = [
11 # Telemetry poller will execute the given period measurements
12 # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
13 {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
14 # Add reporters as children of your supervision tree.
15 # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
16 ]
17
18 1 Supervisor.init(children, strategy: :one_for_one)
19 end
20
21 0 def metrics do
22 [
23 # Phoenix Metrics
24 summary("phoenix.endpoint.stop.duration",
25 unit: {:native, :millisecond}
26 ),
27 summary("phoenix.router_dispatch.stop.duration",
28 tags: [:route],
29 unit: {:native, :millisecond}
30 ),
31
32 # Database Time Metrics
33 summary("hexpm.repo_base.query.total_time", unit: {:native, :millisecond}),
34 summary("hexpm.repo_base.query.decode_time", unit: {:native, :millisecond}),
35 summary("hexpm.repo_base.query.query_time", unit: {:native, :millisecond}),
36 summary("hexpm.repo_base.query.queue_time", unit: {:native, :millisecond}),
37 summary("hexpm.repo_base.query.idle_time", unit: {:native, :millisecond}),
38
39 # VM Metrics
40 summary("vm.memory.total", unit: {:byte, :kilobyte}),
41 summary("vm.total_run_queue_lengths.total"),
42 summary("vm.total_run_queue_lengths.cpu"),
43 summary("vm.total_run_queue_lengths.io")
44 ]
45 end
46
47 1 defp periodic_measurements do
48 []
49 end
50 end

lib/hexpm_web/views/api/audit_log_view.ex

100
1
6
0
Line Hits Source
0 defmodule HexpmWeb.API.AuditLogView do
1 use HexpmWeb, :view
2
3 def render("show", %{audit_log: audit_log}) do
4 6 Map.take(audit_log, [:action, :user_agent, :params])
5 end
6 end

lib/hexpm_web/views/api/download_view.ex

0
2
0
2
Line Hits Source
0 defmodule HexpmWeb.API.DownloadView do
1 use HexpmWeb, :view
2
3 def render("show." <> _, %{download: download}) do
4 0 render_one(download, __MODULE__, "show")
5 end
6
7 def render("show", %{download: download}) do
8 0 %{download.view => download.downloads}
9 end
10 end

lib/hexpm_web/views/api/index_view.ex

100
2
5
0
Line Hits Source
0 defmodule HexpmWeb.API.IndexView do
1 use HexpmWeb, :view
2
3 def render("index." <> _format, _assigns) do
4 1 %{
5 packages_url: Routes.api_package_url(Endpoint, :index),
6 package_url: Routes.api_package_url(Endpoint, :show, "{name}") |> fix_placeholder(),
7 package_release_url:
8 Routes.api_release_url(Endpoint, :show, "{name}", "{version}") |> fix_placeholder(),
9 package_owners_url: Routes.api_owner_url(Endpoint, :index, "{name}") |> fix_placeholder(),
10 keys_url: Routes.api_key_url(Endpoint, :index),
11 key_url: Routes.api_key_url(Endpoint, :show, "{name}") |> fix_placeholder(),
12 documentation_url: "http://docs.hexpm.apiary.io"
13 }
14 end
15
16 defp fix_placeholder(url) do
17 url
18 |> String.replace("%7B", "{")
19 4 |> String.replace("%7D", "}")
20 end
21 end

lib/hexpm_web/views/api/key_permission_view.ex

100
4
60
0
Line Hits Source
0 defmodule HexpmWeb.API.KeyPermissionView do
1 use HexpmWeb, :view
2
3 def render("show." <> _, %{key_permission: key_permission}) do
4 15 render_one(key_permission, __MODULE__, "show")
5 end
6
7 def render("show", %{key_permission: key_permission}) do
8 15 %{
9 15 domain: key_permission.domain,
10 15 resource: key_permission.resource
11 }
12 end
13 end

lib/hexpm_web/views/api/key_view.ex

100
15
156
0
Line Hits Source
0 defmodule HexpmWeb.API.KeyView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.KeyPermissionView
3
4 def render("index." <> _, %{keys: keys, authing_key: authing_key}) do
5 4 render_many(keys, __MODULE__, "show", authing_key: authing_key)
6 end
7
8 def render("show." <> _, %{key: key, authing_key: authing_key}) do
9 5 render_one(key, __MODULE__, "show", authing_key: authing_key)
10 end
11
12 def render("delete." <> _, %{key: key, authing_key: authing_key}) do
13 3 render_one(key, __MODULE__, "show", authing_key: authing_key)
14 end
15
16 def render("show", %{key: key, authing_key: authing_key}) do
17 %{
18 15 name: key.name,
19 15 authing_key: !!(authing_key && key.id == authing_key.id),
20 15 secret: key.user_secret,
21 15 permissions: render_many(key.permissions, KeyPermissionView, "show.json"),
22 15 revoked_at: key.revoked_at,
23 15 inserted_at: key.inserted_at,
24 15 updated_at: key.updated_at,
25 url: Routes.api_key_url(Endpoint, :show, key)
26 }
27 15 |> ViewHelpers.include_if_loaded(:last_use, key.last_use, &render_use/1)
28 end
29
30 defp render_use(use) do
31 6 %{
32 6 used_at: use.used_at,
33 6 ip: use.ip,
34 6 user_agent: use.user_agent
35 }
36 end
37 end

lib/hexpm_web/views/api/organization_user_view.ex

100
4
6
0
Line Hits Source
0 defmodule HexpmWeb.API.OrganizationUserView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.UserView
3
4 def render("index." <> _, %{organization_users: organization_users}) do
5 1 render_many(organization_users, __MODULE__, "show")
6 end
7
8 def render("show." <> _, %{user: user, role: role}) do
9 render_one(user, UserView, "show")
10 3 |> Map.merge(%{role: role})
11 end
12
13 def render("show", %{organization_user: organization_user}) do
14 1 render("show", %{user: organization_user.user, role: organization_user.role})
15 end
16
17 def render("show", %{user: user, role: role}) do
18 render_one(user, UserView, "minimal")
19 1 |> Map.merge(%{role: role})
20 end
21 end

lib/hexpm_web/views/api/organization_view.ex

100
13
20
0
Line Hits Source
0 defmodule HexpmWeb.API.OrganizationView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.OrganizationUserView
3
4 def render("index." <> _, %{organizations: organizations}) do
5 2 Enum.map(organizations, fn organization ->
6 1 %{
7 1 name: organization.name,
8 1 billing_active: organization.billing_active,
9 1 inserted_at: organization.inserted_at,
10 1 updated_at: organization.updated_at
11 }
12 end)
13 end
14
15 def render("show." <> _, %{organization: organization, customer: customer}) do
16 %{
17 2 name: organization.name,
18 2 billing_active: organization.billing_active,
19 2 inserted_at: organization.inserted_at,
20 2 updated_at: organization.updated_at,
21 seats: customer["quantity"]
22 }
23 2 |> ViewHelpers.include_if_loaded(
24 :users,
25 2 organization.organization_users,
26 OrganizationUserView,
27 "show"
28 )
29 end
30
31 def render("audit_logs." <> _, %{audit_logs: audit_logs}) do
32 1 render_many(audit_logs, HexpmWeb.API.AuditLogView, "show")
33 end
34 end

lib/hexpm_web/views/api/owner_view.ex

100
4
20
0
Line Hits Source
0 defmodule HexpmWeb.API.OwnerView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.{OwnerView, UserView}
3
4 def render("index." <> _format, %{owners: owners}) do
5 4 render_many(owners, OwnerView, "show")
6 end
7
8 def render("show." <> _format, %{owner: owner}) do
9 2 render_one(owner, OwnerView, "show")
10 end
11
12 def render("show", %{owner: owner}) do
13 7 render(UserView, "show", user: owner.user)
14 7 |> Map.put(:level, owner.level)
15 end
16 end

lib/hexpm_web/views/api/package_view.ex

92
25
474
2
Line Hits Source
0 defmodule HexpmWeb.API.PackageView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.{DownloadView, ReleaseView, RetirementView, UserView}
3 alias HexpmWeb.PackageView
4
5 def render("index." <> _, %{packages: packages}) do
6 10 render_many(packages, __MODULE__, "show")
7 end
8
9 def render("show." <> _, %{package: package}) do
10 3 render_one(package, __MODULE__, "show")
11 end
12
13 def render("audit_logs." <> _, %{audit_logs: audit_logs}) do
14 1 render_many(audit_logs, HexpmWeb.API.AuditLogView, "show")
15 end
16
17 def render("show", %{package: package}) do
18 23 latest_release = Release.latest_version(package.releases, only_stable: false)
19 23 latest_stable_release = Release.latest_version(package.releases, only_stable: true)
20 23 release = latest_stable_release || latest_release
21
22 %{
23 23 repository: package.repository.name,
24 23 name: package.name,
25 23 inserted_at: package.inserted_at,
26 23 updated_at: package.updated_at,
27 url: ViewHelpers.url_for_package(package),
28 html_url: ViewHelpers.html_url_for_package(package),
29 docs_html_url: ViewHelpers.docs_html_url_for_package(package),
30 23 latest_version: latest_release.version,
31 23 latest_stable_version: latest_stable_release && latest_stable_release.version,
32 configs: %{
33 "mix.exs": PackageView.dep_snippet(:mix, package, release),
34 "rebar.config": PackageView.dep_snippet(:rebar, package, release),
35 "erlang.mk": PackageView.dep_snippet(:erlang_mk, package, release)
36 },
37 meta: %{
38 23 description: package.meta.description,
39 23 licenses: package.meta.licenses || [],
40 23 links: package.meta.links || %{},
41 23 maintainers: package.meta.maintainers || []
42 }
43 }
44 |> ViewHelpers.include_if_loaded(
45 :releases,
46 23 package.releases,
47 ReleaseView,
48 "minimal.json",
49 package: package
50 )
51 |> ViewHelpers.include_if_loaded(
52 :retirements,
53 23 package.releases,
54 RetirementView,
55 "package.json"
56 )
57 23 |> ViewHelpers.include_if_loaded(:downloads, package.downloads, DownloadView, "show.json")
58 23 |> ViewHelpers.include_if_loaded(:owners, package.owners, UserView, "minimal.json")
59 |> group_downloads()
60 23 |> group_retirements()
61 end
62
63 defp group_downloads(%{downloads: downloads} = package) do
64 23 Map.put(package, :downloads, Enum.reduce(downloads, %{}, &Map.merge(&1, &2)))
65 end
66
67 0 defp group_downloads(package), do: package
68
69 defp group_retirements(%{retirements: retirements} = package) do
70 23 Map.put(package, :retirements, Enum.reduce(retirements, %{}, &Map.merge(&1, &2)))
71 end
72
73 0 defp group_retirements(package), do: package
74 end

lib/hexpm_web/views/api/release_view.ex

100
32
914
0
Line Hits Source
0 defmodule HexpmWeb.API.ReleaseView do
1 use HexpmWeb, :view
2 alias HexpmWeb.API.{RetirementView, UserView}
3 alias HexpmWeb.PackageView
4
5 def render("show." <> _, %{release: release}) do
6 33 render_one(release, __MODULE__, "show")
7 end
8
9 def render("minimal." <> _, %{release: release, package: package}) do
10 29 render_one(release, __MODULE__, "minimal", %{package: package})
11 end
12
13 def render("show", %{release: release}) do
14 33 %{
15 33 version: release.version,
16 33 checksum: Base.encode16(release.outer_checksum, case: :lower),
17 33 has_docs: release.has_docs,
18 33 inserted_at: release.inserted_at,
19 33 updated_at: release.updated_at,
20 33 retirement: render_one(release.retirement, RetirementView, "show.json"),
21 33 package_url: ViewHelpers.url_for_package(release.package),
22 33 url: ViewHelpers.url_for_release(release.package, release),
23 33 html_url: ViewHelpers.html_url_for_release(release.package, release),
24 33 docs_html_url: ViewHelpers.docs_html_url_for_release(release.package, release),
25 33 requirements: requirements(release.requirements),
26 configs: %{
27 33 "mix.exs": PackageView.dep_snippet(:mix, release.package, release),
28 33 "rebar.config": PackageView.dep_snippet(:rebar, release.package, release),
29 33 "erlang.mk": PackageView.dep_snippet(:erlang_mk, release.package, release)
30 },
31 meta: %{
32 33 app: release.meta.app,
33 33 build_tools: Enum.uniq(release.meta.build_tools),
34 33 elixir: release.meta.elixir
35 },
36 33 downloads: downloads(release.downloads),
37 33 publisher: render_one(release.publisher, UserView, "minimal.json")
38 }
39 end
40
41 def render("minimal", %{release: release, package: package}) do
42 29 %{
43 29 version: release.version,
44 url: ViewHelpers.url_for_release(package, release),
45 29 has_docs: release.has_docs,
46 29 inserted_at: release.inserted_at
47 }
48 end
49
50 defp requirements(requirements) do
51 33 Enum.into(requirements, %{}, fn req ->
52 4 {req.name, Map.take(req, ~w(app requirement optional)a)}
53 end)
54 end
55
56 25 defp downloads(%Ecto.Association.NotLoaded{}), do: nil
57
58 defp downloads([%Download{day: nil, downloads: downloads}]) do
59 6 downloads
60 end
61
62 defp downloads(downloads) when is_list(downloads) do
63 2 Enum.map(downloads, fn download ->
64 6 [download.day, download.downloads]
65 end)
66 end
67 end

lib/hexpm_web/views/api/repository_view.ex

100
6
24
0
Line Hits Source
0 defmodule HexpmWeb.API.RepositoryView do
1 use HexpmWeb, :view
2
3 def render("index." <> _, %{repositories: repositories}),
4 2 do: render_many(repositories, __MODULE__, "show")
5
6 def render("show." <> _, %{repository: repository}),
7 2 do: render_one(repository, __MODULE__, "show")
8
9 def render("show", %{repository: repository}) do
10 # TODO: Add url
11 # TODO: Add packages
12
13 5 %{
14 5 name: repository.name,
15 5 inserted_at: repository.inserted_at,
16 5 updated_at: repository.updated_at
17 }
18 end
19 end

lib/hexpm_web/views/api/retirement_view.ex

50
8
59
4
Line Hits Source
0 defmodule HexpmWeb.API.RetirementView do
1 use HexpmWeb, :view
2
3 def render("show." <> _, %{retirement: retirement}) do
4 0 render_one(retirement, __MODULE__, "show")
5 end
6
7 def render("package." <> _, %{retirement: retirement}) do
8 29 render_one(retirement, __MODULE__, "package")
9 end
10
11 def render("show", %{retirement: retirement}) do
12 0 %{
13 0 message: retirement.message,
14 0 reason: retirement.reason
15 }
16 end
17
18 28 def render("package", %{retirement: %{retirement: nil}}), do: %{}
19
20 def render("package", %{retirement: %{retirement: retirement, version: version}}) do
21 1 %{
22 1 version => %{reason: retirement.reason, message: retirement.message}
23 }
24 end
25 end

lib/hexpm_web/views/api/short_url_view.ex

100
2
2
0
Line Hits Source
0 defmodule HexpmWeb.API.ShortURLView do
1 use HexpmWeb, :view
2
3 def render("show." <> _, %{url: url}) do
4 1 render(__MODULE__, "show", url: url)
5 end
6
7 def render("show", %{url: url}) do
8 1 %{url: url}
9 end
10 end

lib/hexpm_web/views/api/user_view.ex

93.5
31
312
2
Line Hits Source
0 defmodule HexpmWeb.API.UserView do
1 use HexpmWeb, :view
2
3 def render("index." <> _, %{users: users}) do
4 0 render_many(users, __MODULE__, "show")
5 end
6
7 def render("show." <> _, %{user: user}) do
8 8 render_one(user, __MODULE__, "show")
9 end
10
11 def render("me." <> _, %{user: user}) do
12 1 render_one(user, __MODULE__, "me")
13 end
14
15 def render("minimal." <> _, %{user: user}) do
16 22 render_one(user, __MODULE__, "minimal")
17 end
18
19 def render("audit_logs." <> _, %{audit_logs: audit_logs}) do
20 1 render_many(audit_logs, HexpmWeb.API.AuditLogView, "show")
21 end
22
23 def render("show", %{user: user}) do
24 %{
25 19 username: user.username,
26 19 full_name: user.full_name,
27 handles: handles(user),
28 url: Routes.api_user_url(Endpoint, :show, user),
29 19 inserted_at: user.inserted_at,
30 19 updated_at: user.updated_at
31 }
32 |> put_maybe(:email, User.email(user, :public))
33 19 |> ViewHelpers.include_if_loaded(:owned_packages, user.owned_packages, &owned_packages/1)
34 19 |> ViewHelpers.include_if_loaded(:packages, user.owned_packages, &packages/1)
35 end
36
37 def render("me", %{user: user}) do
38 render_one(user, __MODULE__, "show")
39 1 |> Map.put(:organizations, organizations(user))
40 end
41
42 def render("minimal", %{user: user}) do
43 %{
44 23 username: user.username,
45 url: Routes.api_user_url(Endpoint, :show, user)
46 }
47 23 |> put_maybe(:email, User.email(user, :public))
48 end
49
50 def handles(user) do
51 19 Enum.into(UserHandles.render(user), %{}, fn {field, _service, url} ->
52 {field, url}
53 end)
54 end
55
56 # TODO: deprecated
57 defp owned_packages(packages) do
58 6 Enum.into(packages, %{}, fn package ->
59 7 {package.name, ViewHelpers.url_for_package(package)}
60 end)
61 end
62
63 defp packages(packages) do
64 packages
65 7 |> Enum.sort_by(&[repository_sort(&1), &1.name])
66 6 |> Enum.map(fn package ->
67 7 %{
68 7 name: package.name,
69 repository: repository_name(package),
70 url: ViewHelpers.url_for_package(package),
71 html_url: ViewHelpers.html_url_for_package(package)
72 }
73 end)
74 end
75
76 4 defp repository_name(%Package{repository_id: 1}), do: "hexpm"
77 3 defp repository_name(%Package{repository: %Repository{name: name}}), do: name
78
79 # TODO: DRY up
80 # Atoms sort before strings
81 4 defp repository_sort(%Package{repository_id: 1}), do: :first
82 3 defp repository_sort(%Package{repository: %Repository{name: name}}), do: name
83
84 defp organizations(user) do
85 1 Enum.map(user.organization_users, fn ru ->
86 1 %{
87 1 name: ru.organization.name,
88 1 role: ru.role
89 }
90 end)
91 end
92
93 0 defp put_maybe(map, _key, nil), do: map
94 42 defp put_maybe(map, key, value), do: Map.put(map, key, value)
95 end

lib/hexpm_web/views/blog_view.ex

92.9
14
198
1
Line Hits Source
0 defmodule HexpmWeb.BlogView do
1 use HexpmWeb, :view
2
3 alias Hexpm.Utils
4
5 skip_slugs = ~w()
6
7 all_templates =
8 Phoenix.Template.find_all(@phoenix_root)
9 |> Enum.map(&Phoenix.Template.template_path_to_name(&1, @phoenix_root))
10 |> Enum.flat_map(fn
11 <<n1, n2, n3, "-", slug::binary>> = template
12 when n1 in ?0..?9 and n2 in ?0..?9 and n3 in ?0..?9 ->
13 [{Path.rootname(slug), template}]
14
15 _other ->
16 []
17 end)
18 |> Enum.reject(fn {slug, _template} -> slug in skip_slugs end)
19 |> Enum.sort_by(&elem(&1, 1), &>=/2)
20
21 def render("index.html", _assigns) do
22 0 render_template("index.html", posts: posts())
23 end
24
25 def render("index.xml", _assigns) do
26 1 render_template("index.xml", posts: posts())
27 end
28
29 def render(other, _assigns) do
30 15 content_tag(:div, render_template(other, %{}), class: "show-post")
31 end
32
33 1 def all_templates() do
34 unquote(all_templates)
35 end
36
37 defp posts() do
38 1 Enum.map(all_templates(), fn {slug, template} ->
39 15 content = render(template, %{})
40 15 content = Phoenix.HTML.safe_to_string(content)
41
42 15 %{
43 slug: slug,
44 title: title(content),
45 subtitle: subtitle(content),
46 paragraph: first_paragraph(content),
47 published: published(content)
48 }
49 end)
50 end
51
52 defp first_paragraph(content) do
53 15 regex_run(~r[<p>(.*)</p>]sU, content)
54 end
55
56 defp title(content) do
57 15 regex_run(~r[<h2>(.*)</h2>]sU, content)
58 end
59
60 defp subtitle(content) do
61 15 regex_run(~r[<div class="subtitle">(.*)</div>]sU, content)
62 end
63
64 defp published(content) do
65 15 {:ok, datetime, _utc_offset} =
66 ~r[<time datetime="(.+)">(.+)</time>]sU
67 |> regex_run(content)
68 |> DateTime.from_iso8601()
69
70 15 Utils.datetime_to_rfc2822(datetime)
71 end
72
73 defp regex_run(regex, string) do
74 regex
75 |> Regex.run(string)
76 |> Enum.at(1)
77 60 |> String.trim()
78 end
79 end

lib/hexpm_web/views/dashboard/audit_log_view.ex

100
38
42
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.AuditLogView do
1 use HexpmWeb, :view
2
3 alias HexpmWeb.DashboardView
4
5 @doc """
6 Translate an audit_log to user readable descriptions
7 """
8 def humanize_audit_log_info(%AuditLog{action: "docs.publish", params: params}) do
9 2 "Publish documentation for #{params["package"]["name"]} (#{params["release"]["version"]})"
10 end
11
12 def humanize_audit_log_info(%AuditLog{action: "docs.revert", params: params}) do
13 1 "Revert documentation for #{params["package"]["name"]} (#{params["release"]["version"]})"
14 end
15
16 def humanize_audit_log_info(%AuditLog{action: "key.generate", params: params}) do
17 1 "Generate key #{params["name"]}"
18 end
19
20 def humanize_audit_log_info(%AuditLog{action: "key.remove", params: params}) do
21 1 "Remove key #{params["name"]}"
22 end
23
24 def humanize_audit_log_info(%AuditLog{action: "owner.add", params: params}) do
25 1 "Add #{params["user"]["username"]} as a new owner of package #{params["package"]["name"]}"
26 end
27
28 def humanize_audit_log_info(%AuditLog{action: "owner.transfer", params: params}) do
29 1 "Transfer package #{params["package"]["name"]} to #{params["user"]["username"]}"
30 end
31
32 def humanize_audit_log_info(%AuditLog{action: "owner.remove", params: params}) do
33 1 "Remove #{params["user"]["username"]} from owners of package #{params["package"]["name"]}"
34 end
35
36 def humanize_audit_log_info(%AuditLog{action: "release.publish", params: params}) do
37 1 "Publish package #{params["package"]["name"]} (#{params["release"]["version"]})"
38 end
39
40 def humanize_audit_log_info(%AuditLog{action: "release.revert", params: params}) do
41 1 "Revert package #{params["package"]["name"]} (#{params["release"]["version"]})"
42 end
43
44 def humanize_audit_log_info(%AuditLog{action: "release.retire", params: params}) do
45 1 "Retire package #{params["package"]["name"]} (#{params["release"]["version"]})"
46 end
47
48 def humanize_audit_log_info(%AuditLog{action: "release.unretire", params: params}) do
49 1 "Unretire package #{params["package"]["name"]} (#{params["release"]["version"]})"
50 end
51
52 def humanize_audit_log_info(%AuditLog{action: "email.add", params: params}) do
53 1 "Add email #{params["email"]}"
54 end
55
56 def humanize_audit_log_info(%AuditLog{action: "email.remove", params: params}) do
57 1 "Remove email #{params["email"]}"
58 end
59
60 def humanize_audit_log_info(%AuditLog{action: "email.primary", params: params}) do
61 1 "Set email #{params["email"]} as primary email"
62 end
63
64 def humanize_audit_log_info(%AuditLog{
65 action: "email.public",
66 params: %{"old_email" => old_email, "new_email" => nil}
67 }) do
68 1 "Set email #{old_email["email"]} as private email"
69 end
70
71 def humanize_audit_log_info(%AuditLog{action: "email.public", params: params}) do
72 1 "Set email #{params["email"]} as public email"
73 end
74
75 def humanize_audit_log_info(%AuditLog{action: "email.gravatar", params: params}) do
76 1 "Set email #{params["email"]} as gravatar email"
77 end
78
79 2 def humanize_audit_log_info(%AuditLog{action: "user.create"}) do
80 "Create user account"
81 end
82
83 1 def humanize_audit_log_info(%AuditLog{action: "user.update"}) do
84 "Update user profile"
85 end
86
87 1 def humanize_audit_log_info(%AuditLog{action: "security.update"}) do
88 "Update TFA settings"
89 end
90
91 1 def humanize_audit_log_info(%AuditLog{action: "security.rotate_recovery_codes"}) do
92 "Rotate TFA recovery codes"
93 end
94
95 def humanize_audit_log_info(%AuditLog{action: "organization.create", params: params}) do
96 1 "Create organization #{params["name"]}"
97 end
98
99 def humanize_audit_log_info(%AuditLog{action: "organization.member.add", params: params}) do
100 1 "Add user #{params["user"]["username"]} to organization #{params["organization"]["name"]}"
101 end
102
103 def humanize_audit_log_info(%AuditLog{action: "organization.member.remove", params: params}) do
104 1 "Remove user #{params["user"]["username"]} from organization #{params["organization"]["name"]}"
105 end
106
107 def humanize_audit_log_info(%AuditLog{action: "organization.member.role", params: params}) do
108 1 "Change user #{params["user"]["username"]}'s role to #{params["role"]} " <>
109 1 "in organization #{params["organization"]["name"]}"
110 end
111
112 1 def humanize_audit_log_info(%AuditLog{action: "password.reset.init"}) do
113 "Request to reset password"
114 end
115
116 1 def humanize_audit_log_info(%AuditLog{action: "password.reset.finish"}) do
117 "Reset password successfully"
118 end
119
120 1 def humanize_audit_log_info(%AuditLog{action: "password.update"}) do
121 "Update password"
122 end
123
124 def humanize_audit_log_info(%AuditLog{action: "billing.checkout", params: params}) do
125 1 "Update payment method for organization #{params["organization"]["name"]}"
126 end
127
128 def humanize_audit_log_info(%AuditLog{action: "billing.cancel", params: params}) do
129 1 "Cancel billing on organization #{params["organization"]["name"]}"
130 end
131
132 def humanize_audit_log_info(%AuditLog{action: "billing.create", params: params}) do
133 1 "Add billing information to organization #{params["organization"]["name"]}"
134 end
135
136 def humanize_audit_log_info(%AuditLog{action: "billing.update", params: params}) do
137 1 "Update billing information for organization #{params["organization"]["name"]}"
138 end
139
140 def humanize_audit_log_info(%AuditLog{action: "billing.change_plan", params: params}) do
141 2 "Change billing plan on organization #{params["organization"]["name"]} to " <>
142 2 "#{plan_id(params["plan_id"])}"
143 end
144
145 def humanize_audit_log_info(%AuditLog{action: "billing.pay_invoice", params: params}) do
146 1 "Manually pay invoice for organization #{params["organization"]["name"]}"
147 end
148
149 1 defp plan_id("organization-monthly"), do: "monthly"
150 1 defp plan_id("organization-annually"), do: "annually"
151 end

lib/hexpm_web/views/dashboard/email_view.ex

100
12
20
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.EmailView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3
4 def public_email_options(user) do
5 1 emails =
6 1 user.emails
7 |> Email.order_emails()
8 1 |> Enum.filter(& &1.verified)
9 1 |> Enum.map(&{&1.email, &1.email})
10
11 1 [{"Don't show a public email address", "none"}] ++ emails
12 end
13
14 def public_email_value(user) do
15 1 User.email(user, :public) || "none"
16 end
17
18 def gravatar_email_options(user) do
19 2 emails =
20 2 user.emails
21 3 |> Enum.filter(& &1.verified)
22 2 |> Enum.map(&{&1.email, &1.email})
23
24 2 [{"Don't show an avatar", "none"}] ++ emails
25 end
26
27 def gravatar_email_value(user) do
28 3 User.email(user, :gravatar) || "none"
29 end
30 end

lib/hexpm_web/views/dashboard/key_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.KeyView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3 end

lib/hexpm_web/views/dashboard/organization_view.ex

46.4
56
286
30
Line Hits Source
0 defmodule HexpmWeb.Dashboard.OrganizationView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3
4 defp organization_roles_selector() do
5 11 Enum.map(organization_roles(), fn {name, id, _title} ->
6 {name, id}
7 end)
8 end
9
10 39 defp organization_roles() do
11 [
12 {"Admin", "admin", "This role has full control of the organization"},
13 {"Write", "write", "This role has package owner access to all organization packages"},
14 {"Read", "read", "This role can fetch all organization packages"}
15 ]
16 end
17
18 defp organization_role(id) do
19 14 Enum.find_value(organization_roles(), fn {name, organization_id, _title} ->
20 27 if id == organization_id do
21 14 name
22 end
23 end)
24 end
25
26 1 defp plan("organization-monthly"), do: "Organization, monthly billed ($7.00 per user / month)"
27 0 defp plan("organization-annually"), do: "Organization, annually billed ($70.00 per user / year)"
28 2 defp plan_price("organization-monthly"), do: "$7.00"
29 0 defp plan_price("organization-annually"), do: "$70.00"
30
31 defp proration_description("organization-monthly", price, days, quantity, quantity) do
32 """
33 Each new seat will be prorated on the next invoice for
34 0 <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>.
35 """
36 0 |> raw()
37 end
38
39 defp proration_description("organization-annually", price, days, quantity, quantity) do
40 """
41 Each new seat will be charged a proration for
42 0 <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>.
43 """
44 0 |> raw()
45 end
46
47 defp proration_description("organization-monthly", price, days, quantity, max_period_quantity)
48 when quantity < max_period_quantity do
49 """
50 0 You have already used <strong>#{max_period_quantity}</strong> seats in your current billing period.
51 If adding seats over this amount, each new seat will be prorated on the next invoice for
52 0 <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>.
53 """
54 0 |> raw()
55 end
56
57 defp proration_description("organization-annually", price, days, quantity, max_period_quantity)
58 when quantity < max_period_quantity do
59 """
60 0 You have already used <strong>#{max_period_quantity}</strong> seats in your current billing period.
61 If adding seats over this amount, each new seat will be charged a proration for
62 0 <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>.
63 """
64 0 |> raw()
65 end
66
67 @no_card_message "No payment method on file"
68
69 2 defp payment_card(nil) do
70 @no_card_message
71 end
72
73 0 defp payment_card(%{"brand" => nil}) do
74 @no_card_message
75 end
76
77 defp payment_card(card) do
78 0 card_exp_month = String.pad_leading(card["exp_month"], 2, "0")
79 0 expires = "#{card_exp_month}/#{card["exp_year"]}"
80 0 "#{card["brand"]} **** **** **** #{card["last4"]}, Expires: #{expires}"
81 end
82
83 1 defp subscription_status(%{"status" => "active", "cancel_at_period_end" => false}, _card) do
84 "Active"
85 end
86
87 0 defp subscription_status(%{"status" => "active", "cancel_at_period_end" => true}, _card) do
88 "Ends after current subscription period"
89 end
90
91 defp subscription_status(
92 %{"status" => "trialing", "trial_end" => trial_end},
93 card
94 ) do
95 0 trial_end = trial_end |> NaiveDateTime.from_iso8601!() |> ViewHelpers.pretty_date()
96 0 raw("Trial ends on #{trial_end}, #{trial_status_message(card)}")
97 end
98
99 defp subscription_status(%{"status" => "past_due"}, _card) do
100 0 "Active with past due invoice, if the invoice is not paid the " <>
101 "organization will be disabled"
102 end
103
104 0 defp subscription_status(%{"status" => "incomplete"}, _card) do
105 "TODO"
106 end
107
108 # TODO: Check if last invoice was unpaid and add note about it?
109 0 defp subscription_status(%{"status" => "canceled"}, _card) do
110 "Not active"
111 end
112
113 @trial_ends_no_card_message """
114 your subscription will end after the trial period because we have no payment method on file for you,
115 please enter a payment method if you wish to continue using organizations after the trial period
116 """
117
118 0 defp trial_status_message(%{"brand" => nil}) do
119 @trial_ends_no_card_message
120 end
121
122 0 defp trial_status_message(nil) do
123 @trial_ends_no_card_message
124 end
125
126 0 defp trial_status_message(_card) do
127 "a payment method is on file and your subscription will continue after the trial period"
128 end
129
130 1 defp discount_status(nil) do
131 ""
132 end
133
134 defp discount_status(%{"name" => name, "percent_off" => percent_off}) do
135 0 "(\"#{name}\" discount for #{percent_off}% of price)"
136 end
137
138 1 defp invoice_status(%{"paid" => true}, _organization, _card), do: "Paid"
139 0 defp invoice_status(%{"status" => "uncollectible"}, _organization, _card), do: "Forgiven"
140
141 0 defp invoice_status(%{"paid" => false, "attempted" => false}, _organization, _card),
142 do: "Pending"
143
144 defp invoice_status(%{"paid" => false, "attempted" => true}, _organization, nil = _card) do
145 0 submit(
146 "Pay now",
147 class: "btn btn-primary",
148 disabled: true,
149 title: "No payment method on file"
150 )
151 end
152
153 defp invoice_status(
154 %{"paid" => false, "attempted" => true, "id" => invoice_id},
155 organization,
156 _card
157 ) do
158 0 form_tag(Routes.organization_path(Endpoint, :pay_invoice, organization, invoice_id)) do
159 submit("Pay now", class: "btn btn-primary")
160 end
161 end
162
163 def payment_date(iso_8601_datetime_string) do
164 5 iso_8601_datetime_string |> NaiveDateTime.from_iso8601!() |> ViewHelpers.pretty_date()
165 end
166
167 defp money(integer) when is_integer(integer) and integer >= 0 do
168 2 whole = div(integer, 100)
169 2 float = rem(integer, 100) |> Integer.to_string() |> String.pad_leading(2, "0")
170 2 "#{whole}.#{float}"
171 end
172
173 defp default_billing_emails(user, billing_email) do
174 14 emails =
175 14 user.emails
176 14 |> Enum.filter(& &1.verified)
177 14 |> Enum.map(& &1.email)
178
179 [billing_email | emails]
180 28 |> Enum.reject(&is_nil/1)
181 14 |> Enum.uniq()
182 end
183
184 # From Hexpm.Billing.Country
185 @country_codes [
186 {"AD", "Andorra"},
187 {"AE", "United Arab Emirates"},
188 {"AF", "Afghanistan"},
189 {"AG", "Antigua and Barbuda"},
190 {"AI", "Anguilla"},
191 {"AL", "Albania"},
192 {"AM", "Armenia"},
193 {"AO", "Angola"},
194 {"AQ", "Antarctica"},
195 {"AR", "Argentina"},
196 {"AS", "American Samoa"},
197 {"AT", "Austria"},
198 {"AU", "Australia"},
199 {"AW", "Aruba"},
200 {"AX", "Ã…land Islands"},
201 {"AZ", "Azerbaijan"},
202 {"BA", "Bosnia and Herzegovina"},
203 {"BB", "Barbados"},
204 {"BD", "Bangladesh"},
205 {"BE", "Belgium"},
206 {"BF", "Burkina Faso"},
207 {"BG", "Bulgaria"},
208 {"BH", "Bahrain"},
209 {"BI", "Burundi"},
210 {"BJ", "Benin"},
211 {"BL", "Saint Barthélemy"},
212 {"BM", "Bermuda"},
213 # Brunei Darussalam
214 {"BN", "Brunei"},
215 # Bolivia, Plurinational State
216 {"BO", "Bolivia"},
217 # Bonaire, Sint Eustatius and Saba
218 {"BQ", "Bonaire"},
219 {"BR", "Brazil"},
220 {"BS", "Bahamas"},
221 {"BT", "Bhutan"},
222 {"BV", "Bouvet Island"},
223 {"BW", "Botswana"},
224 {"BY", "Belarus"},
225 {"BZ", "Belize"},
226 {"CA", "Canada"},
227 {"CC", "Cocos (Keeling) Islands"},
228 {"CD", "Congo, the Democratic Republic of the"},
229 {"CF", "Central African Republic"},
230 {"CG", "Congo"},
231 {"CH", "Switzerland"},
232 {"CI", "Côte d'Ivoire"},
233 {"CK", "Cook Islands"},
234 {"CL", "Chile"},
235 {"CM", "Cameroon"},
236 {"CN", "China"},
237 {"CO", "Colombia"},
238 {"CR", "Costa Rica"},
239 {"CU", "Cuba"},
240 {"CV", "Cabo Verde"},
241 {"CW", "Curaçao"},
242 {"CX", "Christmas Island"},
243 {"CY", "Cyprus"},
244 # Czechia (Changed for Stripe compatibility)
245 {"CZ", "Czech Republic"},
246 {"DE", "Germany"},
247 {"DJ", "Djibouti"},
248 {"DK", "Denmark"},
249 {"DM", "Dominica"},
250 {"DO", "Dominican Republic"},
251 {"DZ", "Algeria"},
252 {"EC", "Ecuador"},
253 {"EE", "Estonia"},
254 {"EG", "Egypt"},
255 {"EH", "Western Sahara"},
256 {"ER", "Eritrea"},
257 {"ES", "Spain"},
258 {"ET", "Ethiopia"},
259 {"FI", "Finland"},
260 {"FJ", "Fiji"},
261 # Falkland Islands (Malvinas)
262 {"FK", "Falkland Island"},
263 # Micronesia, Federated States of
264 {"FM", "Micronesia"},
265 {"FO", "Faroe Islands"},
266 {"FR", "France"},
267 {"GA", "Gabon"},
268 # United Kingdom of Great Britain and Northern Ireland
269 {"GB", "United Kingdom"},
270 {"GD", "Grenada"},
271 {"GE", "Georgia"},
272 {"GF", "French Guiana"},
273 {"GG", "Guernsey"},
274 {"GH", "Ghana"},
275 {"GI", "Gibraltar"},
276 {"GL", "Greenland"},
277 {"GM", "Gambia"},
278 {"GN", "Guinea"},
279 {"GP", "Guadeloupe"},
280 {"GQ", "Equatorial Guinea"},
281 {"GR", "Greece"},
282 # South Georgia and the South Sandwich Islands
283 {"GS", "South Georgia"},
284 {"GT", "Guatemala"},
285 {"GU", "Guam"},
286 {"GW", "Guinea-Bissau"},
287 {"GY", "Guyana"},
288 {"HK", "Hong Kong"},
289 {"HM", "Heard Island and McDonald Islands"},
290 {"HN", "Honduras"},
291 {"HR", "Croatia"},
292 {"HT", "Haiti"},
293 {"HU", "Hungary"},
294 {"ID", "Indonesia"},
295 {"IE", "Ireland"},
296 {"IL", "Israel"},
297 {"IM", "Isle of Man"},
298 {"IN", "India"},
299 {"IO", "British Indian Ocean Territory"},
300 {"IQ", "Iraq"},
301 # Iran, Islamic Republic
302 {"IR", "Iran"},
303 {"IS", "Iceland"},
304 {"IT", "Italy"},
305 {"JE", "Jersey"},
306 {"JM", "Jamaica"},
307 {"JO", "Jordan"},
308 {"JP", "Japan"},
309 {"KE", "Kenya"},
310 {"KG", "Kyrgyzstan"},
311 {"KH", "Cambodia"},
312 {"KI", "Kiribati"},
313 {"KM", "Comoros"},
314 {"KN", "Saint Kitts and Nevis"},
315 {"KP", "Korea, Democratic People's Republic of"},
316 {"KR", "Korea, Republic of"},
317 {"KW", "Kuwait"},
318 {"KY", "Cayman Islands"},
319 {"KZ", "Kazakhstan"},
320 # Lao People's Democratic Republic
321 {"LA", "Laos"},
322 {"LB", "Lebanon"},
323 {"LC", "Saint Lucia"},
324 {"LI", "Liechtenstein"},
325 {"LK", "Sri Lanka"},
326 {"LR", "Liberia"},
327 {"LS", "Lesotho"},
328 {"LT", "Lithuania"},
329 {"LU", "Luxembourg"},
330 {"LV", "Latvia"},
331 {"LY", "Libya"},
332 {"MA", "Morocco"},
333 {"MC", "Monaco"},
334 {"MD", "Moldova , Republic"},
335 {"ME", "Montenegro"},
336 # Saint Martin (French part)
337 {"MF", "Saint Martin"},
338 {"MG", "Madagascar"},
339 {"MH", "Marshall Islands"},
340 {"MK", "Macedonia"},
341 {"ML", "Mali"},
342 {"MM", "Myanmar"},
343 {"MN", "Mongolia"},
344 {"MO", "Macao"},
345 {"MP", "Northern Mariana Islands"},
346 {"MQ", "Martinique"},
347 {"MR", "Mauritania"},
348 {"MS", "Montserrat"},
349 {"MT", "Malta"},
350 {"MU", "Mauritius"},
351 {"MV", "Maldives"},
352 {"MW", "Malawi"},
353 {"MX", "Mexico"},
354 {"MY", "Malaysia"},
355 {"MZ", "Mozambique"},
356 {"NA", "Namibia"},
357 {"NC", "New Caledonia"},
358 {"NE", "Niger"},
359 {"NF", "Norfolk Island"},
360 {"NG", "Nigeria"},
361 {"NI", "Nicaragua"},
362 {"NL", "Netherlands"},
363 {"NO", "Norway"},
364 {"NP", "Nepal"},
365 {"NR", "Nauru"},
366 {"NU", "Niue"},
367 {"NZ", "New Zealand"},
368 {"OM", "Oman"},
369 {"PA", "Panama"},
370 {"PE", "Peru"},
371 {"PF", "French Polynesia"},
372 {"PG", "Papua New Guinea"},
373 {"PH", "Philippines"},
374 {"PK", "Pakistan"},
375 {"PL", "Poland"},
376 {"PM", "Saint Pierre and Miquelon"},
377 {"PN", "Pitcairn"},
378 {"PR", "Puerto Rico"},
379 # Palestine, State of
380 {"PS", "Palestin"},
381 {"PT", "Portugal"},
382 {"PW", "Palau"},
383 {"PY", "Paraguay"},
384 {"QA", "Qatar"},
385 {"RE", "Réunion"},
386 {"RO", "Romania"},
387 {"RS", "Serbia"},
388 # Russian Federation
389 {"RU", "Russia"},
390 {"RW", "Rwanda"},
391 {"SA", "Saudi Arabia"},
392 {"SB", "Solomon Islands"},
393 {"SC", "Seychelles"},
394 {"SD", "Sudan"},
395 {"SE", "Sweden"},
396 {"SG", "Singapore"},
397 {"SH", "Saint Helena, Ascension and Tristan da Cunha"},
398 {"SI", "Slovenia"},
399 {"SJ", "Svalbard and Jan Mayen"},
400 {"SK", "Slovakia"},
401 {"SL", "Sierra Leone"},
402 {"SM", "San Marino"},
403 {"SN", "Senegal"},
404 {"SO", "Somalia"},
405 {"SR", "Suriname"},
406 {"SS", "South Sudan"},
407 {"ST", "Sao Tome and Principe"},
408 {"SV", "El Salvador"},
409 # Sint Maarten (Dutch part)
410 {"SX", "Sint Maarten"},
411 # Syrian Arab Republic
412 {"SY", "Syria"},
413 {"SZ", "Swaziland"},
414 {"TC", "Turks and Caicos Islands"},
415 {"TD", "Chad"},
416 {"TF", "French Southern Territories"},
417 {"TG", "Togo"},
418 {"TH", "Thailand"},
419 {"TJ", "Tajikistan"},
420 {"TK", "Tokelau"},
421 {"TL", "Timor-Leste"},
422 {"TM", "Turkmenistan"},
423 {"TN", "Tunisia"},
424 {"TO", "Tonga"},
425 {"TR", "Turkey"},
426 {"TT", "Trinidad and Tobago"},
427 {"TV", "Tuvalu"},
428 # Taiwan, Province of China
429 {"TW", "Taiwan"},
430 # Tanzania, United Republic of
431 {"TZ", "Tanzania"},
432 {"UA", "Ukraine"},
433 {"UG", "Uganda"},
434 {"UM", "United States Minor Outlying Islands"},
435 # United States of America
436 {"US", "United States"},
437 {"UY", "Uruguay"},
438 {"UZ", "Uzbekistan"},
439 {"VA", "Holy See"},
440 {"VC", "Saint Vincent and the Grenadines"},
441 # Venezuela, Bolivarian Republic of
442 {"VE", "Venezuela"},
443 # Virgin Islands, British
444 {"VG", "British Virgin Islands"},
445 # Virgin Islands, U.S.
446 {"VI", "United States Virgin Islands"},
447 {"VN", "Viet Nam"},
448 {"VU", "Vanuatu"},
449 {"WF", "Wallis and Futuna"},
450 {"WS", "Samoa"},
451 {"YE", "Yemen"},
452 {"YT", "Mayotte"},
453 {"ZA", "South Africa"},
454 {"ZM", "Zambia"},
455 {"ZW", "Zimbabwe"}
456 ]
457
458 14 defp countries() do
459 unquote([{"", ""}] ++ Enum.sort_by(@country_codes, &elem(&1, 1)))
460 end
461
462 defp show_person?(person, errors) do
463 14 (person || errors["person"]) && !errors["company"]
464 end
465
466 defp show_company?(company, errors) do
467 14 (company || errors["company"]) && !errors["person"]
468 end
469
470 defp organization_admin?(current_user, organization) do
471 11 user = Enum.find(organization.organization_users, &(&1.user_id == current_user.id))
472 11 user.role == "admin"
473 end
474 end

lib/hexpm_web/views/dashboard/password_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.PasswordView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3 end

lib/hexpm_web/views/dashboard/profile_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.ProfileView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3
4 import HexpmWeb.Dashboard.EmailView,
5 only: [
6 public_email_options: 1,
7 public_email_value: 1,
8 gravatar_email_options: 1,
9 gravatar_email_value: 1
10 ]
11 end

lib/hexpm_web/views/dashboard/security_view.ex

100
6
8
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.SecurityView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3 alias Hexpm.Accounts.User
4
5 defp show_recovery_codes?(user) do
6 1 User.tfa_enabled?(user) && user.tfa.recovery_codes
7 end
8
9 defp class_for_code(code) do
10 2 case code.used_at do
11 1 nil -> "recovery-code-unused"
12 1 _ -> "recovery-code-used"
13 end
14 end
15
16 defp aggregate_recovery_codes(codes) do
17 2 Enum.map(codes, & &1.code)
18 1 |> Enum.reduce(fn code, acc -> acc <> "\n" <> code end)
19 end
20 end

lib/hexpm_web/views/dashboard/tfa_auth_setup_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.Dashboard.TFAAuthSetupView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DashboardView
3 end

lib/hexpm_web/views/dashboard_view.ex

42.9
7
193
4
Line Hits Source
0 defmodule HexpmWeb.DashboardView do
1 use HexpmWeb, :view
2
3 26 defp account_settings() do
4 [
5 profile: {"Profile", Routes.profile_path(Endpoint, :index)},
6 password: {"Password", Routes.dashboard_password_path(Endpoint, :index)},
7 security: {"Security", Routes.dashboard_security_path(Endpoint, :index)},
8 email: {"Emails", Routes.email_path(Endpoint, :index)},
9 keys: {"Keys", Routes.key_path(Endpoint, :index)},
10 audit_logs: {"Recent activities", Routes.audit_log_path(Endpoint, :index)}
11 ]
12 end
13
14 defp selected_setting(conn, id) do
15 156 if Enum.take(conn.path_info, -2) == ["dashboard", Atom.to_string(id)] do
16 "selected"
17 end
18 end
19
20 defp selected_organization(conn, name) do
21 11 if Enum.take(conn.path_info, -2) == ["orgs", name] do
22 "selected"
23 end
24 end
25
26 0 defp permission_name(%KeyPermission{domain: "api", resource: nil}),
27 do: "API"
28
29 defp permission_name(%KeyPermission{domain: "api", resource: resource}),
30 0 do: "API:#{resource}"
31
32 defp permission_name(%KeyPermission{domain: "repository", resource: resource}),
33 0 do: "REPO:#{resource}"
34
35 0 defp permission_name(%KeyPermission{domain: "repositories"}),
36 do: "REPOS"
37 end

lib/hexpm_web/views/docs_view.ex

0
1
0
1
Line Hits Source
0 defmodule HexpmWeb.DocsView do
1 use HexpmWeb, :view
2 alias HexpmWeb.DocsView
3
4 def selected_docs(conn, view) do
5 0 if conn.assigns.view_name == view do
6 "selected"
7 else
8 ""
9 end
10 end
11 end

lib/hexpm_web/views/email_verification_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.EmailVerificationView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/email_view.ex

87.9
33
758
4
Line Hits Source
0 defmodule HexpmWeb.EmailView do
1 use HexpmWeb, :view
2
3 defmodule OwnerAdd do
4 def message(username, package) do
5 22 "#{username} has been added as an owner to package #{package}."
6 end
7 end
8
9 defmodule OwnerRemove do
10 def message(username, package) do
11 8 "#{username} has been removed from owners of package #{package}."
12 end
13 end
14
15 defmodule Verification do
16 50 def intro() do
17 "To begin using your email, we require you to verify your email address."
18 end
19 end
20
21 defmodule PasswordResetRequest do
22 22 def title() do
23 "Reset your Hex.pm password"
24 end
25
26 22 def message() do
27 "We heard you've lost your password to Hex.pm. Sorry about that!"
28 end
29
30 22 def mix_code() do
31 "mix hex.user auth"
32 end
33
34 22 def rebar_code() do
35 "rebar3 hex user auth"
36 end
37
38 22 def before_code() do
39 "Once this is complete, your existing keys may be invalidated, you will need to regenerate them by running:"
40 end
41
42 22 def after_code() do
43 "and entering your username and password."
44 end
45 end
46
47 defmodule PasswordChanged do
48 def greeting(username) do
49 4 "Hello #{username}"
50 end
51
52 4 def title() do
53 "Your password on Hex.pm has been changed."
54 end
55 end
56
57 defmodule TyposquatCandidates do
58 def intro(threshold) do
59 0 """
60 0 Using Levenshtein Distance with a threshold of #{threshold}
61 --------------------
62 new_package,current_package,distance
63 """
64 end
65
66 def table(candidates) do
67 candidates
68 0 |> Enum.map(fn [n, c, d] -> "#{n},#{c},#{d}" end)
69 0 |> Enum.join("\n")
70 end
71 end
72
73 defmodule OrganizationInvite do
74 6 def access_organization() do
75 "You can access organization packages after authenticating in your shell:"
76 end
77
78 6 def mix_code() do
79 "mix hex.user auth"
80 end
81
82 6 def rebar_code() do
83 "rebar3 hex user auth"
84 end
85 end
86
87 defmodule PackagePublished do
88 def intro(nil, package, version) do
89 6 """
90 6 Package #{package} v#{version} was recently published.
91 If this wasn't done by you or one of the other package owners, you should
92 reset your account and revert or retire the version.
93 """
94 end
95
96 def intro(publisher, package, version) do
97 50 """
98 50 Package #{package} v#{version} was recently published by #{publisher.username}.
99 If this wasn't done by you or one of the other package owners, you should
100 reset your account and revert or retire the version.
101 """
102 end
103
104 def mix_code(package, version) do
105 56 """
106 56 cd #{package}; mix hex.publish --revert #{version}
107 # or
108 56 mix hex.retire #{package} #{version} security --message "Not published by owners"
109 """
110 end
111
112 def rebar3_code(package, version) do
113 56 """
114 56 cd #{package}; rebar3 hex publish --revert #{version}
115 # or
116 56 rebar3 hex retire #{package} #{version} security --message "Not published by owners"
117 """
118 end
119 end
120
121 defmodule ReportState do
122 14 def state_explain("to_accept") do
123 """
124 The report has now state \"to_accept\".
125 This means that the vulnerability reported has to be reviewed by a moderator in order to be recognized or not as a real vulnerability.
126 Only the report author and moderators can see the report description.
127 """
128 end
129
130 30 def state_explain("accepted") do
131 """
132 The report has now state \"accepted\".
133 This means that the vulnerability reported has been recognized by a moderator as real.
134 A comments section has been enabled on the report for moderators, owners and the report author to discuss the vulnerability.
135 """
136 end
137
138 12 def state_explain("solved") do
139 """
140 The report has now state \"solved\".
141 This means that the vulnerability reported has been solved.
142 Now the report is public, so users other than the report author, moderators and the reported package owners can read the report description.
143 """
144 end
145
146 8 def state_explain("rejected") do
147 """
148 The report has now state \"rejected\".
149 This means that the vulnerability reported has not been recognized as such a vulnerability by a moderator.
150 The report will not be made public, so users other than the report author and moderators will not be able to read the report description or the comments section.
151 Moderators and the report author can still comment about the report on the report's comment section.
152 """
153 end
154
155 8 def state_explain("unresolved") do
156 """
157 The report has now state \"unresolved\".
158 This means the report has been on a revision state (\"accepted\") for too long.
159 Now the report is public, so users other than the report author, moderators and the reported package owners can read the report description.
160 """
161 end
162 end
163 end

lib/hexpm_web/views/emails_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.EmailsView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/error_view.ex

84.6
13
434
2
Line Hits Source
0 defmodule HexpmWeb.ErrorView do
1 use HexpmWeb, :view
2
3 def render(<<status::binary-3>> <> ".html", assigns) when status != "all" do
4 24 render(
5 "all.html",
6 24 conn: assigns.conn,
7 error: true,
8 status: status,
9 message: message(status),
10 container: "container error-view",
11 current_user: assigns[:current_user]
12 )
13 end
14
15 def render(<<status::binary-3>> <> _, assigns) when status != "all" do
16 assigns
17 |> Map.take([:message, :errors])
18 |> Map.put(:status, String.to_integer(status))
19 181 |> Map.put_new(:message, message(status))
20 end
21
22 # In case no render clause matches or no
23 # template is found, let's render it as a 500
24 def template_not_found(_template, assigns) do
25 0 render(
26 "all.html",
27 0 conn: assigns.conn,
28 error: true,
29 status: "500",
30 message: "Internal server error",
31 current_user: assigns[:current_user]
32 )
33 end
34
35 4 defp message("400"), do: "Bad request"
36 115 defp message("404"), do: "Page not found"
37 1 defp message("408"), do: "Request timeout"
38 1 defp message("413"), do: "Payload too large"
39 1 defp message("415"), do: "Unsupported media type"
40 26 defp message("422"), do: "Validation error(s)"
41 2 defp message("500"), do: "Internal server error"
42 55 defp message(_), do: nil
43 end

lib/hexpm_web/views/icons.ex

100
20
2870
0
Line Hits Source
0 defmodule HexpmWeb.ViewIcons do
1 use Phoenix.HTML
2 import SweetXml
3
4 @icons_dir Path.join(__DIR__, "../../../assets/vendor/icons")
5 @octicons_svg Path.join(@icons_dir, "octicons.svg")
6 @glyphicons_svg Path.join(@icons_dir, "glyphicons-halflings-regular.svg")
7 @glyphicons_less Path.join(@icons_dir, "glyphicons.less")
8
9 @external_resource @octicons_svg
10 @external_resource @glyphicons_svg
11 @external_resource @glyphicons_less
12
13 :ok = Application.ensure_loaded(:xmerl)
14 {:ok, xmerl_version} = :application.get_key(:xmerl, :vsn)
15
16 xmerl_version =
17 xmerl_version
18 |> List.to_string()
19 |> String.split(".")
20 |> Enum.concat(["0"])
21 |> Enum.take(3)
22 |> Enum.join(".")
23
24 broken_xmerl? = Version.compare(xmerl_version, "1.3.20") == :lt
25
26 doc = File.read!(@octicons_svg)
27
28 octicons =
29 SweetXml.xpath(
30 doc,
31 ~x"//glyph"l,
32 name: ~x"./@glyph-name"s,
33 d: ~x"./@d"s,
34 x: ~x"./@horiz-adv-x"s
35 )
36
37 91 defp octicon(name) when is_atom(name), do: octicon(Atom.to_string(name))
38
39 Enum.each(octicons, fn %{name: name, d: d, x: x} ->
40 29 defp octicon(unquote(name)), do: {unquote(d), unquote(x)}
41 end)
42
43 doc = File.read!(@glyphicons_svg)
44
45 glyphicons =
46 SweetXml.xpath(
47 doc,
48 ~x"//glyph[@unicode][@d]"l,
49 unicode: if(broken_xmerl?, do: ~x"./@unicode", else: ~x"./@unicode"s),
50 d: ~x"./@d"s,
51 x: ~x"./@horiz-adv-x"s
52 )
53
54 lines =
55 File.read!(@glyphicons_less)
56 |> String.split("\n", trim: true)
57
58 @glyphicon_less_regex ~r'\.glyphicon-([-\w]+)\s*\{ &:before \{ content: "\\([0-9a-f]{4})"; \} \}'
59 glyphicon_names =
60 Enum.reduce(lines, %{}, fn line, map ->
61 case Regex.run(@glyphicon_less_regex, line) do
62 [_, name, content] ->
63 Map.put(map, content, name)
64
65 nil ->
66 map
67 end
68 end)
69
70 230 defp glyphicon(name) when is_atom(name), do: glyphicon(Atom.to_string(name))
71
72 Enum.each(glyphicons, fn %{unicode: unicode, d: d, x: x} ->
73 unicode = if broken_xmerl?, do: IO.iodata_to_binary(Enum.reverse(unicode)), else: unicode
74
75 name =
76 case unicode do
77 <<char::utf8>> ->
78 codepoint =
79 char
80 |> Integer.to_string(16)
81 |> String.pad_leading(4, "0")
82 |> String.downcase()
83
84 Map.get(glyphicon_names, codepoint)
85 end
86
87 if name do
88 115 defp glyphicon(unquote(name)), do: {unquote(d), unquote(x)}
89 end
90 end)
91
92 220 def icon(type, name, opts \\ [])
93
94 def icon(:octicon, name, opts) do
95 91 class = "octicon octicon-#{name}"
96 91 {d, x} = octicon(name)
97 91 title = if title = opts[:title], do: content_tag(:title, title), else: ""
98
99 91 opts =
100 opts
101 |> Keyword.put_new(:"aria-hidden", "true")
102 |> Keyword.put_new(:version, "1.1")
103 91 |> Keyword.put_new(:viewBox, "0 0 #{x} 1024")
104 22 |> Keyword.update(:class, class, &"#{class} #{&1}")
105 |> Keyword.drop([:title])
106
107 91 content_tag :svg, opts do
108 content_tag :g, transform: "translate(0, 800) scale(1, -1)" do
109 [content_tag(:path, "", d: d), title]
110 end
111 end
112 end
113
114 def icon(:glyphicon, name, opts) do
115 230 class = "glyphicon glyphicon-#{name}"
116 230 {d, x} = glyphicon(name)
117 230 x = if x == "", do: "1200", else: x
118 230 title = if title = opts[:title], do: content_tag(:title, title), else: ""
119
120 230 opts =
121 opts
122 |> Keyword.put_new(:"aria-hidden", "true")
123 |> Keyword.put_new(:version, "1.1")
124 230 |> Keyword.put_new(:viewBox, "0 0 #{x} 1200")
125 7 |> Keyword.update(:class, class, &"#{class} #{&1}")
126 |> Keyword.drop([:title])
127
128 230 content_tag :svg, opts do
129 content_tag :g, transform: "translate(0, 1200) scale(1, -1)" do
130 [content_tag(:path, "", d: d), title]
131 end
132 end
133 end
134 end

lib/hexpm_web/views/layout_view.ex

100
10
1018
0
Line Hits Source
0 defmodule HexpmWeb.LayoutView do
1 use HexpmWeb, :view
2
3 def show_search?(assigns) do
4 115 Map.get(assigns, :hide_search) != true
5 end
6
7 def title(assigns) do
8 115 if title = Map.get(assigns, :title) do
9 73 "#{title} | Hex"
10 else
11 "Hex"
12 end
13 end
14
15 def description(assigns) do
16 230 if description = Map.get(assigns, :description) do
17 18 String.slice(description, 0, 160)
18 else
19 "A package manager for the Erlang ecosystem"
20 end
21 end
22
23 def canonical_url(assigns) do
24 115 if url = Map.get(assigns, :canonical_url) do
25 9 tag(:link, rel: "canonical", href: url)
26 else
27 nil
28 end
29 end
30
31 def search(assigns) do
32 113 Map.get(assigns, :search)
33 end
34
35 def container_class(assigns) do
36 115 Map.get(assigns, :container, "container")
37 end
38
39 115 def og_tags(assigns) do
40 [
41 tag(:meta, property: "og:title", content: Map.get(assigns, :title)),
42 tag(:meta, property: "og:type", content: "website"),
43 tag(:meta, property: "og:url", content: Map.get(assigns, :canonical_url)),
44 tag(
45 :meta,
46 property: "og:image",
47 content: Routes.static_url(HexpmWeb.Endpoint, "/images/favicon-160.png")
48 ),
49 tag(:meta, property: "og:image:width", content: "160"),
50 tag(:meta, property: "og:image:height", content: "160"),
51 tag(:meta, property: "og:description", content: description(assigns)),
52 tag(:meta, property: "og:site_name", content: "Hex")
53 ]
54 end
55 end

lib/hexpm_web/views/login_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.LoginView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/opensearch_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.OpenSearchView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/package_report_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.PackageReportView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/package_view.ex

94.9
99
1523
5
Line Hits Source
0 defmodule HexpmWeb.PackageView do
1 use HexpmWeb, :view
2
3 1 def show_sort_info(nil), do: show_sort_info(:name)
4 2 def show_sort_info(:name), do: "Sort: Name"
5 1 def show_sort_info(:inserted_at), do: "Sort: Recently created"
6 1 def show_sort_info(:updated_at), do: "Sort: Recently updated"
7 1 def show_sort_info(:total_downloads), do: "Sort: Total downloads"
8 5 def show_sort_info(:recent_downloads), do: "Sort: Recent downloads"
9 1 def show_sort_info(_param), do: nil
10
11 def downloads_for_package(package, downloads) do
12 14 Map.get(downloads, package.id, %{"all" => 0, "recent" => 0})
13 end
14
15 def display_downloads(package_downloads, view) do
16 28 case view do
17 :recent_downloads ->
18 14 Map.get(package_downloads, "recent")
19
20 _ ->
21 14 Map.get(package_downloads, "all")
22 end
23 end
24
25 def display_downloads_for_opposite_views(package_downloads, view) do
26 14 case view do
27 :recent_downloads ->
28 14 downloads = display_downloads(package_downloads, :all) || 0
29 14 "total downloads: #{ViewHelpers.human_number_space(downloads)}"
30
31 _ ->
32 0 downloads = display_downloads(package_downloads, :recent_downloads) || 0
33 0 "recent downloads: #{ViewHelpers.human_number_space(downloads)}"
34 end
35 end
36
37 def display_downloads_view_title(view) do
38 14 case view do
39 14 :recent_downloads -> "recent downloads"
40 0 _ -> "total downloads"
41 end
42 end
43
44 def dep_snippet(:mix, package, release) do
45 69 version = snippet_version(:mix, release.version)
46 69 app_name = (release.meta && release.meta.app) || package.name
47 69 organization = snippet_organization(package.repository.name)
48
49 69 if package.name == app_name do
50 57 "{:#{package.name}, \"#{version}\"#{organization}}"
51 else
52 12 "{#{app_name(:mix, app_name)}, \"#{version}\", hex: :#{package.name}#{organization}}"
53 end
54 end
55
56 def dep_snippet(:rebar, package, release) do
57 68 version = snippet_version(:rebar, release.version)
58 68 app_name = (release.meta && release.meta.app) || package.name
59
60 68 if package.name == app_name do
61 56 "{#{package.name}, \"#{version}\"}"
62 else
63 12 "{#{app_name(:rebar, app_name)}, \"#{version}\", {pkg, #{package.name}}}"
64 end
65 end
66
67 def dep_snippet(:erlang_mk, package, release) do
68 66 version = snippet_version(:erlang_mk, release.version)
69 66 "dep_#{package.name} = hex #{version}"
70 end
71
72 def snippet_version(:mix, %Version{major: 0, minor: minor, patch: patch, pre: []}) do
73 48 "~> 0.#{minor}.#{patch}"
74 end
75
76 def snippet_version(:mix, %Version{major: major, minor: minor, pre: []}) do
77 24 "~> #{major}.#{minor}"
78 end
79
80 def snippet_version(:mix, %Version{major: major, minor: minor, patch: patch, pre: pre}) do
81 1 "~> #{major}.#{minor}.#{patch}#{pre_snippet(pre)}"
82 end
83
84 def snippet_version(other, %Version{major: major, minor: minor, patch: patch, pre: pre})
85 when other in [:rebar, :erlang_mk] do
86 142 "#{major}.#{minor}.#{patch}#{pre_snippet(pre)}"
87 end
88
89 55 defp snippet_organization("hexpm"), do: ""
90 14 defp snippet_organization(repository), do: ", organization: #{inspect(repository)}"
91
92 140 defp pre_snippet([]), do: ""
93
94 defp pre_snippet(pre) do
95 3 "-" <>
96 Enum.map_join(pre, ".", fn
97 6 int when is_integer(int) -> Integer.to_string(int)
98 3 string when is_binary(string) -> string
99 end)
100 end
101
102 @elixir_atom_chars ~r"^[a-zA-Z_][a-zA-Z_0-9]*$"
103 @erlang_atom_chars ~r"^[a-z][a-zA-Z_0-9]*$"
104
105 defp app_name(:mix, name) do
106 12 if Regex.match?(@elixir_atom_chars, name) do
107 11 ":#{name}"
108 else
109 1 ":#{inspect(name)}"
110 end
111 end
112
113 defp app_name(:rebar, name) do
114 12 if Regex.match?(@erlang_atom_chars, name) do
115 11 name
116 else
117 1 inspect(String.to_charlist(name))
118 end
119 end
120
121 def retirement_message(retirement) do
122 4 reason = ReleaseRetirement.reason_text(retirement.reason)
123
124 4 head =
125 4 case retirement.reason do
126 0 "report" -> ["Marked package"]
127 4 _ -> ["Retired package"]
128 end
129
130 4 body =
131 cond do
132 4 reason && retirement.message ->
133 1 [": ", reason, " - ", retirement.message]
134
135 3 reason ->
136 [": ", reason]
137
138 2 retirement.message ->
139 1 [": ", retirement.message]
140
141 1 true ->
142 []
143 end
144
145 4 head ++ body
146 end
147
148 def retirement_html(retirement) do
149 4 reason = ReleaseRetirement.reason_text(retirement.reason)
150
151 4 msg_head =
152 4 case retirement.reason do
153 0 "report" -> [content_tag(:strong, "Marked package:")]
154 4 _ -> [content_tag(:strong, "Retired package:")]
155 end
156
157 4 msg_body =
158 cond do
159 4 reason && retirement.message ->
160 1 [" ", reason, " - ", retirement.message]
161
162 3 reason ->
163 [" ", reason]
164
165 2 retirement.message ->
166 1 [" ", retirement.message]
167
168 1 true ->
169 []
170 end
171
172 4 msg_head ++ msg_body
173 end
174
175 def path_for_audit_logs(package, options) do
176 9 if package.repository.id == 1 do
177 7 Routes.package_path(Endpoint, :audit_logs, package, options)
178 else
179 2 Routes.package_path(Endpoint, :audit_logs, package.repository, package, options)
180 end
181 end
182
183 @doc """
184 This function turns an audit_log struct into a short description.
185
186 Please check Hexpm.Accounts.AuditLog.extract_params/2 to see all the
187 package related actions and their params structures.
188 """
189 def humanize_audit_log_info(%{action: "docs.publish"} = audit_log) do
190 4 if release_version = audit_log.params["release"]["version"] do
191 1 "Publish documentation for release #{release_version}"
192 else
193 "Publish documentation"
194 end
195 end
196
197 def humanize_audit_log_info(%{action: "docs.revert"} = audit_log) do
198 3 if release_version = audit_log.params["release"]["version"] do
199 1 "Revert documentation for release #{release_version}"
200 else
201 "Revert documentation"
202 end
203 end
204
205 def humanize_audit_log_info(%{action: "owner.add"} = audit_log) do
206 3 username = audit_log.params["user"]["username"]
207 3 level = audit_log.params["level"]
208
209 3 if username && level do
210 1 "Add #{username} as a level #{level} owner"
211 else
212 "Add owner"
213 end
214 end
215
216 def humanize_audit_log_info(%{action: "owner.transfer"} = audit_log) do
217 2 if username = audit_log.params["user"]["username"] do
218 1 "Transfer owner to #{username}"
219 else
220 "Transfer owner"
221 end
222 end
223
224 def humanize_audit_log_info(%{action: "owner.remove"} = audit_log) do
225 3 username = audit_log.params["user"]["username"]
226 3 level = audit_log.params["level"]
227
228 3 if username && level do
229 1 "Remove level #{level} owner #{username}"
230 else
231 "Remove owner"
232 end
233 end
234
235 def humanize_audit_log_info(%{action: "release.publish"} = audit_log) do
236 3 if version = audit_log.params["release"]["version"] do
237 1 "Publish release #{version}"
238 else
239 "Publish release"
240 end
241 end
242
243 def humanize_audit_log_info(%{action: "release.revert"} = audit_log) do
244 3 if version = audit_log.params["release"]["version"] do
245 1 "Revert release #{version}"
246 else
247 "Revert release"
248 end
249 end
250
251 def humanize_audit_log_info(%{action: "release.retire"} = audit_log) do
252 3 if version = audit_log.params["release"]["version"] do
253 1 "Retire release #{version}"
254 else
255 "Retire release"
256 end
257 end
258
259 def humanize_audit_log_info(%{action: "release.unretire"} = audit_log) do
260 3 if version = audit_log.params["release"]["version"] do
261 1 "Unretire release #{version}"
262 else
263 "Unretire release"
264 end
265 end
266 end

lib/hexpm_web/views/page_view.ex

100
2
22
0
Line Hits Source
0 defmodule HexpmWeb.PageView do
1 use HexpmWeb, :view
2
3 def render_package(data) do
4 11 data =
5 [
6 downloads: nil,
7 description: nil,
8 inserted_at: nil,
9 version: nil
10 ]
11 |> Keyword.merge(data)
12
13 11 render("_package.html", data)
14 end
15 end

lib/hexpm_web/views/password_reset_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.PasswordResetView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/password_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.PasswordView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/policy_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.PolicyView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/shared_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.SharedView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/signup_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.SignupView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/sitemap_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.SitemapView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/tfa_auth_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.TFAAuthView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/tfa_recovery_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.TFARecoveryView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/user_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.UserView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/version_view.ex

0
0
0
0
Line Hits Source
0 defmodule HexpmWeb.VersionView do
1 use HexpmWeb, :view
2 end

lib/hexpm_web/views/view_helpers.ex

78.3
143
4270
31
Line Hits Source
0 defmodule HexpmWeb.ViewHelpers do
1 use Phoenix.HTML
2 alias Hexpm.Repository.{Package, Release}
3 alias HexpmWeb.Endpoint
4 alias HexpmWeb.Router.Helpers, as: Routes
5
6 def logged_in?(assigns) do
7 115 !!assigns[:current_user]
8 end
9
10 def package_name(package) do
11 43 package_name(package.repository.name, package.name)
12 end
13
14 def package_name("hexpm", package) do
15 34 package
16 end
17
18 def package_name(repository, package) do
19 9 repository <> " / " <> package
20 end
21
22 def path_for_package(package) do
23 25 if package.repository.id == 1 do
24 20 Routes.package_path(Endpoint, :show, package, [])
25 else
26 5 Routes.package_path(Endpoint, :show, package.repository, package, [])
27 end
28 end
29
30 def path_for_package("hexpm", package) do
31 0 Routes.package_path(Endpoint, :show, package, [])
32 end
33
34 def path_for_package(repository, package) do
35 0 Routes.package_path(Endpoint, :show, repository, package, [])
36 end
37
38 def path_for_release(package, release) do
39 29 if package.repository.id == 1 do
40 25 Routes.package_path(Endpoint, :show, package, release, [])
41 else
42 4 Routes.package_path(Endpoint, :show, package.repository, package, release, [])
43 end
44 end
45
46 def path_for_releases(package) do
47 0 if package.repository.id == 1 do
48 0 Routes.version_path(Endpoint, :index, package, [])
49 else
50 0 Routes.version_path(Endpoint, :index, package.repository, package, [])
51 end
52 end
53
54 def html_url_for_package(%Package{repository_id: 1} = package) do
55 23 Routes.package_url(Endpoint, :show, package, [])
56 end
57
58 def html_url_for_package(%Package{} = package) do
59 7 Routes.package_url(Endpoint, :show, package.repository, package, [])
60 end
61
62 def html_url_for_release(%Package{repository_id: 1} = package, release) do
63 26 Routes.package_url(Endpoint, :show, package, release, [])
64 end
65
66 def html_url_for_release(%Package{} = package, release) do
67 7 Routes.package_url(Endpoint, :show, package.repository, package, release, [])
68 end
69
70 def docs_html_url_for_package(package) do
71 23 if Enum.any?(package.releases, & &1.has_docs) do
72 17 Hexpm.Utils.docs_html_url(package.repository, package, nil)
73 end
74 end
75
76 27 def docs_html_url_for_release(_package, %Release{has_docs: false}) do
77 nil
78 end
79
80 def docs_html_url_for_release(package, release) do
81 6 Hexpm.Utils.docs_html_url(package.repository, package, release)
82 end
83
84 def url_for_package(%Package{repository_id: 1} = package) do
85 53 Routes.api_package_url(Endpoint, :show, package, [])
86 end
87
88 def url_for_package(package) do
89 17 Routes.api_package_url(Endpoint, :show, package.repository, package, [])
90 end
91
92 def url_for_release(%Package{repository_id: 1} = package, release) do
93 51 Routes.api_release_url(Endpoint, :show, package, release, [])
94 end
95
96 def url_for_release(%Package{} = package, release) do
97 11 Routes.api_release_url(
98 Endpoint,
99 :show,
100 11 package.repository,
101 package,
102 11 to_string(release.version),
103 []
104 )
105 end
106
107 def gravatar_url(nil, size) do
108 0 "https://www.gravatar.com/avatar?s=#{gravatar_size(size)}&d=mm"
109 end
110
111 def gravatar_url(email, size) do
112 96 hash =
113 :crypto.hash(:md5, String.trim(email))
114 |> Base.encode16(case: :lower)
115
116 96 "https://www.gravatar.com/avatar/#{hash}?s=#{gravatar_size(size)}&d=retro"
117 end
118
119 15 defp gravatar_size(:large), do: 440
120 81 defp gravatar_size(:small), do: 80
121
122 def changeset_error(changeset) do
123 24 if changeset.action do
124 6 content_tag :div, class: "alert alert-danger" do
125 "Oops, something went wrong! Please check the errors below."
126 end
127 end
128 end
129
130 def text_input(form, field, opts \\ []) do
131 115 value = form.params[Atom.to_string(field)] || Map.get(form.data, field)
132
133 115 opts =
134 opts
135 |> add_error_class(form, field)
136 |> Keyword.put_new(:value, value)
137
138 115 Phoenix.HTML.Form.text_input(form, field, opts)
139 end
140
141 def email_input(form, field, opts \\ []) do
142 6 value = form.params[Atom.to_string(field)] || Map.get(form.data, field)
143
144 6 opts =
145 opts
146 |> add_error_class(form, field)
147 |> Keyword.put_new(:value, value)
148
149 6 Phoenix.HTML.Form.email_input(form, field, opts)
150 end
151
152 def password_input(form, field, opts \\ []) do
153 18 opts = add_error_class(opts, form, field)
154 18 Phoenix.HTML.Form.password_input(form, field, opts)
155 end
156
157 def select(form, field, options, opts \\ []) do
158 13 opts = add_error_class(opts, form, field)
159 13 Phoenix.HTML.Form.select(form, field, options, opts)
160 end
161
162 defp add_error_class(opts, form, field) do
163 152 error? = Keyword.has_key?(form.errors, field)
164 152 error_class = if error?, do: "form-input-error", else: ""
165 152 class = "form-control #{error_class} #{opts[:class]}"
166
167 152 Keyword.put(opts, :class, class)
168 end
169
170 def error_tag(form, field) do
171 153 if error = form.errors[field] do
172 6 content_tag(:span, translate_error(error), class: "form-error")
173 end
174 end
175
176 defp translate_error({msg, opts}) do
177 6 Enum.reduce(opts, msg, fn {key, value}, msg ->
178 5 String.replace(msg, "%{#{key}}", to_string(value))
179 end)
180 end
181
182 def paginate(page, count, opts) do
183 11 per_page = opts[:items_per_page]
184 # Needs to be odd number
185 11 max_links = opts[:page_links]
186
187 11 all_pages = div(count - 1, per_page) + 1
188 11 middle_links = div(max_links, 2) + 1
189
190 11 page_links =
191 cond do
192 page < middle_links ->
193 11 Enum.take(1..max_links, all_pages)
194
195 0 page > all_pages - middle_links ->
196 0 start =
197 0 if all_pages > middle_links + 1 do
198 0 all_pages - (middle_links + 1)
199 else
200 1
201 end
202
203 0 Enum.to_list(start..all_pages)
204
205 0 true ->
206 0 Enum.to_list((page - 2)..(page + 2))
207 end
208
209 11 %{prev: page != 1, next: page != all_pages, page_links: page_links}
210 end
211
212 def params(list) do
213 30 Enum.filter(list, fn {_, v} -> present?(v) end)
214 end
215
216 0 def present?(""), do: false
217 50 def present?(nil), do: false
218 40 def present?(_), do: true
219
220 def text_length(text, length) when byte_size(text) > length do
221 0 :binary.part(text, 0, length - 3) <> "..."
222 end
223
224 def text_length(text, _length) do
225 46 text
226 end
227
228 44 def human_number_space(0, _max), do: "0"
229
230 def human_number_space(int, max) when is_integer(int) do
231 19 unit =
232 cond do
233 3 int >= 1_000_000_000 -> {"B", 9}
234 16 int >= 1_000_000 -> {"M", 6}
235 11 int >= 1_000 -> {"K", 3}
236 4 true -> {"", 1}
237 end
238
239 19 do_human_number(int, max, trunc(:math.log10(int)) + 1, unit)
240 end
241
242 def human_number_space(number) do
243 number
244 104 |> to_string()
245 |> String.to_charlist()
246 |> Enum.reverse()
247 |> Enum.chunk_every(3)
248 |> Enum.intersperse(?\s)
249 |> List.flatten()
250 |> Enum.reverse()
251 104 |> :erlang.list_to_binary()
252 end
253
254 defp do_human_number(int, max, digits, _unit) when is_integer(int) and digits <= max do
255 9 human_number_space(int)
256 end
257
258 defp do_human_number(int, max, digits, {unit, mag}) when is_integer(int) and digits > max do
259 10 shifted = int / :math.pow(10, mag)
260 10 len = trunc(:math.log10(shifted)) + 2
261 10 float = Float.round(shifted, max - len)
262
263 10 case Float.ratio(float) do
264 5 {_, 1} -> human_number_space(trunc(float)) <> unit
265 5 {_, _} -> to_string(float) <> unit
266 end
267 end
268
269 def human_relative_time_from_now(datetime) do
270 26 ts = NaiveDateTime.to_erl(datetime) |> :calendar.datetime_to_gregorian_seconds()
271 26 diff = :calendar.datetime_to_gregorian_seconds(:calendar.universal_time()) - ts
272 26 rel = rel_from_now(:calendar.seconds_to_daystime(diff))
273
274 26 content_tag(:span, rel, title: pretty_date(datetime))
275 end
276
277 15 defp rel_from_now({0, {0, 0, sec}}) when sec < 30, do: "about now"
278 0 defp rel_from_now({0, {0, min, _}}) when min < 2, do: "1 minute ago"
279 0 defp rel_from_now({0, {0, min, _}}), do: "#{min} minutes ago"
280 0 defp rel_from_now({0, {1, _, _}}), do: "1 hour ago"
281 0 defp rel_from_now({0, {hour, _, _}}) when hour < 24, do: "#{hour} hours ago"
282 0 defp rel_from_now({1, {_, _, _}}), do: "1 day ago"
283 0 defp rel_from_now({day, {_, _, _}}) when day < 0, do: "about now"
284 11 defp rel_from_now({day, {_, _, _}}), do: "#{day} days ago"
285
286 def pretty_datetime(datetime) do
287 40 Calendar.strftime(datetime, "%b %d, %Y, %H:%M")
288 end
289
290 def pretty_date(date) do
291 31 Calendar.strftime(date, "%B %d, %Y")
292 end
293
294 def pretty_date(date, :short) do
295 40 Calendar.strftime(date, "%b %d, %Y")
296 end
297
298 0 def if_value(arg, nil, _fun), do: arg
299 0 def if_value(arg, false, _fun), do: arg
300 0 def if_value(arg, _true, fun), do: fun.(arg)
301
302 def safe_join(enum, separator, fun \\ & &1) do
303 0 Enum.map_join(enum, separator, &safe_to_string(fun.(&1)))
304 9 |> raw()
305 end
306
307 71 def include_if_loaded(output, key, struct, view, name \\ "show.json", assigns \\ %{})
308
309 def include_if_loaded(output, _key, %Ecto.Association.NotLoaded{}, _view, _name, _assigns) do
310 48 output
311 end
312
313 def include_if_loaded(output, _key, nil, _view, _name, _assigns) do
314 9 output
315 end
316
317 def include_if_loaded(output, key, struct, fun, _name, _assigns) when is_function(fun, 1) do
318 18 Map.put(output, key, fun.(struct))
319 end
320
321 def include_if_loaded(output, key, structs, view, name, assigns) when is_list(structs) do
322 72 Map.put(output, key, Phoenix.View.render_many(structs, view, name, assigns))
323 end
324
325 def include_if_loaded(output, key, struct, view, name, assigns) do
326 0 Map.put(output, key, Phoenix.View.render_one(struct, view, name, assigns))
327 end
328
329 def auth_qr_code_svg(user) do
330 1 "otpauth://totp/hex.pm:#{user.username}?issuer=hex.pm&secret=#{user.tfa.secret}"
331 |> EQRCode.encode()
332 1 |> EQRCode.svg(width: 250)
333 end
334
335 # assumes positive values only, and graph dimensions of 800 x 200
336 def time_series_graph(points) do
337 9 max =
338 Enum.max(points ++ [5])
339 |> rounded_max()
340
341 9 y_axis_labels = y_axis_labels(0, max)
342
343 9 calculated_points =
344 points
345 279 |> Enum.map(fn p -> points_to_graph(max, p) end)
346 |> Enum.zip(x_axis_points(length(points)))
347
348 9 polyline_points = to_polyline_points(calculated_points)
349 9 polyline_fill = to_polyline_fill(calculated_points)
350
351 9 {y_axis_labels, polyline_points, polyline_fill}
352 end
353
354 defp points_to_graph(max, data) do
355 279 px_per_point = 200 / max
356 279 198 - (data |> Kernel.*(px_per_point) |> Float.round(3))
357 end
358
359 defp x_axis_points(total_points) do
360 # width / points captured
361 9 px_per_point = Float.round(800 / total_points, 2)
362 9 Enum.map(0..total_points, &Kernel.*(&1, px_per_point))
363 end
364
365 defp to_polyline_points(list) do
366 9 Enum.reduce(list, "", fn {y, x}, acc -> acc <> "#{x}, #{y} " end)
367 end
368
369 defp to_polyline_fill(list) do
370 9 top = Enum.reduce(list, "", fn {y, x}, acc -> acc <> "#{x}, #{y} " end)
371 9 {_last_y, last_x} = List.last(list)
372 9 fill = "#{last_x}, 200 0, 200"
373 9 top <> fill
374 end
375
376 defp y_axis_labels(min, max) do
377 9 div = (rounded_max(max) - min) / 5
378
379 [
380 min,
381 round(div),
382 round(div * 2),
383 round(div * 3),
384 round(div * 4)
385 ]
386 end
387
388 defp rounded_max(max) do
389 18 case max do
390 0 max when max > 1_000_000 -> max |> Kernel./(1_000_000) |> ceil |> Kernel.*(1_000_000)
391 0 max when max > 100_000 -> max |> Kernel./(100_000) |> ceil |> Kernel.*(100_000)
392 0 max when max > 10_000 -> max |> Kernel./(10_000) |> ceil |> Kernel.*(10_000)
393 0 max when max > 1_000 -> max |> Kernel./(1_000) |> ceil |> Kernel.*(1_000)
394 0 max when max > 100 -> 1_000
395 18 _ -> 100
396 end
397 end
398 end
399
400 defimpl Phoenix.HTML.Safe, for: Version do
401 55 def to_iodata(version), do: String.Chars.Version.to_string(version)
402 end

lib/hexpm_web/web.ex

0
1
0
1
Line Hits Source
0 defmodule HexpmWeb do
1 @moduledoc """
2 A module that keeps using definitions for controllers,
3 views and so on.
4
5 This can be used in your application as:
6
7 use HexpmWeb, :controller
8 use HexpmWeb, :view
9
10 The definitions below will be executed for every view,
11 controller, etc, so keep them short and clean, focused
12 on imports, uses and aliases.
13
14 Do NOT define functions inside the quoted expressions
15 below.
16 """
17
18 def controller() do
19 quote do
20 use Phoenix.Controller, namespace: HexpmWeb
21
22 import Ecto
23 import Ecto.Query, only: [from: 1, from: 2]
24
25 import HexpmWeb.{ControllerHelpers, AuthHelpers}
26
27 alias HexpmWeb.{Endpoint, Router}
28 alias HexpmWeb.Router.Helpers, as: Routes
29
30 use Hexpm.Shared
31 end
32 end
33
34 def view() do
35 quote do
36 use Phoenix.View,
37 root: "lib/hexpm_web/templates",
38 namespace: HexpmWeb
39
40 use Phoenix.HTML
41
42 # Import convenience functions from controllers
43 import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]
44
45 # Use all HTML functionality (forms, tags, etc)
46 import Phoenix.HTML.Form,
47 except: [
48 text_input: 2,
49 text_input: 3,
50 email_input: 2,
51 email_input: 3,
52 password_input: 2,
53 password_input: 3,
54 select: 3,
55 select: 4
56 ]
57
58 import HexpmWeb.ViewIcons
59
60 alias HexpmWeb.ViewHelpers
61 alias HexpmWeb.{Endpoint, Router}
62 alias HexpmWeb.Router.Helpers, as: Routes
63
64 use Hexpm.Shared
65 end
66 end
67
68 def router() do
69 quote do
70 use Phoenix.Router
71 import HexpmWeb.Plugs
72
73 alias HexpmWeb.{Endpoint, Router}
74 alias HexpmWeb.Router.Helpers, as: Routes
75 end
76 end
77
78 @doc """
79 When used, dispatch to the appropriate controller/view/etc.
80 """
81 defmacro __using__(which) when is_atom(which) do
82 0 apply(__MODULE__, which, [])
83 end
84 end

test/support/case.ex

100
6
439
0
Line Hits Source
0 defmodule Hexpm.Case do
1 def key_for(user_or_organization) do
2 183 {:ok, %{key: key}} =
3 Hexpm.Accounts.Keys.create(
4 user_or_organization,
5 %{name: "any_key_name"},
6 audit: nil
7 )
8
9 183 key.user_secret
10 end
11
12 def read_fixture(path) do
13 Path.join([__DIR__, "..", "fixtures", path])
14 4 |> File.read!()
15 end
16
17 def audit_data(user) do
18 62 {user, "TEST", "127.0.0.1"}
19 end
20
21 def default_meta(name, version) do
22 6 %{
23 "name" => name,
24 "description" => "description",
25 "licenses" => [],
26 "version" => version,
27 "requirements" => [],
28 "app" => name,
29 "build_tools" => ["mix"],
30 "files" => ["mix.exs"]
31 }
32 end
33
34 def default_requirement(name, requirement) do
35 1 %{"name" => name, "app" => name, "requirement" => requirement, "optional" => false}
36 end
37 end

test/support/conn_case.ex

100
6
1198
0
Line Hits Source
0 defmodule HexpmWeb.ConnCase do
1 @moduledoc """
2 This module defines the test case to be used by
3 tests that require setting up a connection.
4
5 Such tests rely on `Phoenix.ConnTest` and also
6 imports other functionality to make it easier
7 to build and query models.
8
9 Finally, if the test case interacts with the database,
10 it cannot be async. For this reason, every test runs
11 inside a transaction which is reset at the beginning
12 of the test unless the test case is marked as async.
13 """
14
15 use ExUnit.CaseTemplate
16
17 49 using do
18 quote do
19 # Import conveniences for testing with connections
20 alias Hexpm.{Fake, Repo}
21 alias HexpmWeb.Router.Helpers, as: Routes
22
23 import Ecto
24 import Ecto.Query, only: [from: 2]
25 import Plug.Conn
26 import Phoenix.ConnTest
27 import Hexpm.{Case, Factory, TestHelpers}
28 import unquote(__MODULE__)
29
30 # The default endpoint for testing
31 @endpoint HexpmWeb.Endpoint
32 end
33 end
34
35 setup do
36 507 :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hexpm.RepoBase)
37 507 Bamboo.SentEmail.reset()
38 :ok
39 end
40
41 def test_login(conn, user) do
42 126 Plug.Test.init_test_session(conn, %{"user_id" => user.id})
43 end
44
45 def last_session() do
46 import Ecto.Query
47
48 from(s in Hexpm.Accounts.Session, order_by: [desc: s.id], limit: 1)
49 6 |> Hexpm.Repo.one()
50 end
51
52 def json_post(conn, path, params) do
53 conn
54 |> Plug.Conn.put_req_header("content-type", "application/json")
55 3 |> Phoenix.ConnTest.dispatch(HexpmWeb.Endpoint, :post, path, Jason.encode!(params))
56 end
57 end

test/support/data_case.ex

75
4
196
1
Line Hits Source
0 defmodule Hexpm.DataCase do
1 @moduledoc """
2 This module defines the test case to be used by
3 model tests.
4
5 You may define functions here to be used as helpers in
6 your model tests. See `errors_on/2`'s definition as reference.
7
8 Finally, if the test case interacts with the database,
9 it cannot be async. For this reason, every test runs
10 inside a transaction which is reset at the beginning
11 of the test unless the test case is marked as async.
12 """
13
14 use ExUnit.CaseTemplate
15
16 19 using do
17 quote do
18 import Ecto
19 import Ecto.Query, only: [from: 2]
20 import Hexpm.{Case, DataCase, Factory, TestHelpers}
21
22 alias Hexpm.{Fake, Repo}
23 end
24 end
25
26 setup do
27 154 :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hexpm.RepoBase)
28
29 :ok
30 end
31
32 @doc """
33 Helper for returning list of errors in model when passed certain data.
34
35 ## Examples
36
37 Given a User model that lists `:name` as a required field and validates
38 `:password` to be safe, it would return:
39
40 iex> errors_on(%User{}, %{password: "password"})
41 [password: "is unsafe", name: "is blank"]
42
43 You could then write your assertion like:
44
45 assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"})
46
47 You can also create the changeset manually and retrieve the errors
48 field directly:
49
50 iex> changeset = User.changeset(%User{}, password: "password")
51 iex> {:password, "is unsafe"} in changeset.errors
52 true
53 """
54 def errors_on(model, data) do
55 0 model.__struct__.changeset(model, data).errors
56 end
57
58 def errors_on(%Ecto.Changeset{} = changeset) do
59 23 HexpmWeb.ControllerHelpers.translate_errors(changeset)
60 end
61 end

test/support/test_helpers.ex

100
27
1379
0
Line Hits Source
0 defmodule Hexpm.TestHelpers do
1 @tmp Application.compile_env(:hexpm, :tmp_dir)
2
3 def create_tar(meta, files \\ [{"mix.exs", "mix.exs"}]) do
4 49 meta =
5 meta
6 |> Map.put_new(:app, meta[:name])
7 |> Map.put_new(:build_tools, ["mix"])
8 |> Map.put_new(:licenses, ["Apache-2.0"])
9 |> Map.put_new(:requirements, %{})
10 49 |> Map.put_new(:files, Enum.map(files, &elem(&1, 0)))
11
12 49 contents_path = Path.join(@tmp, "#{meta[:name]}-#{meta[:version]}-contents.tar.gz")
13 49 files = Enum.map(files, fn {name, bin} -> {String.to_charlist(name), bin} end)
14 49 :ok = :erl_tar.create(contents_path, files, [:compressed])
15 49 contents = File.read!(contents_path)
16
17 49 meta_string = HexpmWeb.ConsultFormat.encode(meta)
18 49 blob = "3" <> meta_string <> contents
19 49 checksum = :crypto.hash(:sha256, blob) |> Base.encode16()
20
21 49 files = [
22 {'VERSION', "3"},
23 {'CHECKSUM', checksum},
24 {'metadata.config', meta_string},
25 {'contents.tar.gz', contents}
26 ]
27
28 49 path = Path.join(@tmp, "#{meta[:name]}-#{meta[:version]}.tar")
29 49 :ok = :erl_tar.create(path, files)
30
31 49 File.read!(path)
32 end
33
34 def rel_meta(params) do
35 46 params = params(params)
36
37 46 meta =
38 params
39 |> Map.put_new("build_tools", ["mix"])
40 |> Map.put_new("files", ["mix.exs"])
41
42 params
43 |> Map.put("meta", meta)
44 46 |> Map.update("requirements", [], &requirements_meta/1)
45 end
46
47 def pkg_meta(meta) do
48 11 params = params(meta)
49 11 meta = Map.put_new(params, "licenses", ["Apache-2.0"])
50 11 Map.put(params, "meta", meta)
51 end
52
53 def params(params) when is_map(params) do
54 77 Enum.into(params, %{}, fn
55 3 {binary, value} when is_binary(binary) -> {binary, params(value)}
56 209 {atom, value} when is_atom(atom) -> {Atom.to_string(atom), params(value)}
57 end)
58 end
59
60 16 def params(params) when is_list(params), do: Enum.map(params, &params/1)
61 198 def params(other), do: other
62
63 def mock_pwned() do
64 38 Mox.stub(Hexpm.Pwned.Mock, :password_breached?, fn _password -> false end)
65 end
66
67 defp requirements_meta(list) do
68 14 Enum.map(list, fn req ->
69 req
70 |> Map.put_new("repository", "hexpm")
71 |> Map.put_new("optional", false)
72 16 |> Map.put_new("app", req["name"])
73 end)
74 end
75 end